diff --git a/docs/developers/permission-system.md b/docs/developers/permission-system.md new file mode 100644 index 000000000..d174577ec --- /dev/null +++ b/docs/developers/permission-system.md @@ -0,0 +1,601 @@ +# Permission System 实现方案 + +## 概述 + +本文档描述了将 qwen-code 现有的 `tools.core` / `tools.exclude` / `tools.allowed` 配置方案升级为统一 Permission System 的完整实现方案。新方案对齐 Claude Code 的 Permission 设计,引入 `allow` / `ask` / `deny` 三态规则体系,并通过 `PermissionManager` 统一管控,同时提供完整的交互式 `/permissions` 对话框 UI。 + +--- + +## 背景与动机 + +### 现有方案的局限性 + +当前系统通过三个配置项管控工具权限: + +- **`tools.core`**(白名单):只有列出的工具才能注册启用。一旦非空,未列出的工具全部禁用。 +- **`tools.exclude`**(黑名单):列出的工具从注册中排除,模型无法调用。优先级最高。 +- **`tools.allowed`**(免确认列表):列出的工具调用时跳过用户确认弹窗,不影响工具是否可用。 + +主要不足: + +1. **无 `ask` 独立规则**:无法针对某个工具单独设定"每次必须询问",只能依赖全局 `approvalMode`。 +2. **文件/路径级别无法控制**:无法表达"允许读文件但禁止读 `.env`"这类精细权限。 +3. **Shell 命令通配符能力弱**:`tools.allowed` 的命令匹配只支持简单前缀,无法表达 `git * main` 这类中间通配。 +4. **规则分散**:权限逻辑散落在 `tool-utils.ts`、`shell-utils.ts`、`coreToolScheduler.ts` 多处,维护困难。 +5. **无 UI 管理入口**:缺少交互式规则管理界面,用户只能手动编辑 `settings.json`。 + +--- + +## 设计原则 + +1. **旧配置项彻底删除**:`tools.core` / `tools.exclude` / `tools.allowed` 随新版本完全移除,代码中不保留任何对旧配置的读取或兼容逻辑;存在旧配置的用户须通过启动时一键迁移功能完成迁移,迁移前旧配置不会生效。 +2. **Manager 模式**:完全对齐项目现有的 `SkillManager` / `SubagentManager` 编码风格,通过 `config.getPermissionManager()` 对外暴露唯一实例。 +3. **不引入系统级 managed-settings**:不新增 macOS `/Library/Application Support/` 等系统级配置文件支持。 +4. **配置层级精简为三层**:User(`~/.qwen/settings.json`)、Workspace(`.qwen/settings.json`)、System(已有的 `getSystemSettingsPath()`),与现有 `LoadedSettings` / `SettingScope` 体系完全一致。 + +--- + +## 核心概念 + +### 规则格式 + +``` +Tool # 匹配该工具的所有调用 +Tool(specifier) # 匹配带特定参数的调用 +``` + +**示例**: + +- `Bash` — 匹配所有 Shell 命令 +- `Bash(git *)` — 匹配所有以 `git` 开头的命令 +- `Bash(git * main)` — 匹配如 `git checkout main`、`git merge main` +- `Bash(* --version)` — 匹配任意工具的 `--version` 查询 +- `read_file(./secrets/**)` — 匹配读取 `secrets/` 目录下任意文件(gitignore 路径语法) +- `run_shell_command(rm -rf *)` — 匹配危险删除命令 + +### 规则求值顺序(first-match-wins) + +$$\text{deny} \rightarrow \text{ask} \rightarrow \text{allow}$$ + +`deny` 规则优先级最高。第一条匹配的规则即为最终决策,后续规则不再评估。 + +### 三种决策结果 + +| 决策 | 含义 | +| --------- | --------------------------------------------- | +| `allow` | 自动批准,无需用户确认 | +| `ask` | 每次调用前弹出确认对话框 | +| `deny` | 直接拒绝,工具调用返回错误 | +| `default` | 无规则匹配,回退到 `defaultMode` 全局模式处理 | + +### 配置存储位置 + +规则存储在各级 `settings.json` 的 `permissions` 字段下: + +```json +{ + "permissions": { + "allow": ["Bash(npm run *)", "Bash(git commit *)"], + "ask": ["Bash(git push *)"], + "deny": ["Bash(rm -rf *)", "read_file(./.env)"] + } +} +``` + +--- + +## 模块结构 + +### 新增模块:`packages/core/src/permissions/` + +``` +packages/core/src/permissions/ +├── types.ts # 类型定义 +├── rule-parser.ts # 规则解析与匹配 +├── permission-manager.ts # 核心 Manager 类 +└── index.ts # 对外导出 +``` + +### 文件职责说明 + +#### `types.ts` + +定义以下核心类型: + +- **`PermissionDecision`**:`'allow' | 'ask' | 'deny' | 'default'` +- **`PermissionRule`**:解析后的规则对象,包含原始字符串、工具名、可选 specifier +- **`PermissionRuleSet`**:三组规则的集合(allow / ask / deny 数组) +- **`PermissionCheckContext`**:权限检查时的上下文,包含工具名和可选的调用参数 +- **`RuleWithSource`**:带来源信息的规则,用于 `/permissions` 对话框展示(规则内容 + 规则类型 + 来源 scope) + +#### `rule-parser.ts` + +负责规则的解析和匹配逻辑,是纯函数模块,无副作用: + +- **规则解析**:将 `"Bash(git *)"` 字符串解析为结构化的 `PermissionRule` 对象 +- **工具名规范化**:处理工具别名映射(如 `ShellTool` / `run_shell_command` / `Bash` 的等价关系) +- **Shell 命令 glob 匹配**: + - `*` 通配符可出现在命令的任意位置(头部、中间、尾部) + - 空格前的 `*` 强制单词边界:`Bash(ls *)` 匹配 `ls -la` 但不匹配 `lsof` + - 无空格的 `Bash(ls*)` 匹配 `ls -la` 和 `lsof` 两者 + - 识别 shell 操作符(`&&`、`|`、`;` 等),前缀匹配规则不跨操作符生效 +- **文件路径匹配**(用于 `read_file` / `edit_file` 类规则): + - 遵循 gitignore 路径规范 + - `//path`:从文件系统根开始的绝对路径 + - `~/path`:相对于用户主目录 + - `/path`:相对于项目根目录 + - `./path` 或无前缀:相对于当前工作目录 + - `*` 匹配单层目录内文件,`**` 递归匹配多层 + +#### `permission-manager.ts` + +`PermissionManager` 类,是整个权限系统的核心。 + +**构造器**:接收 `config: Config`,与 `SkillManager` 完全一致。 + +**初始化逻辑**: + +1. 读取 `settings.permissions.allow` / `ask` / `deny`,合并为最终规则集 +2. 初始化会话级规则集合(内存中,不持久化) + +**核心方法**: + +- **`evaluate(context: PermissionCheckContext): PermissionDecision`** + 主决策方法。按 deny → ask → allow 顺序评估规则,first-match-wins。无匹配时返回 `'default'`,由调用方根据 `getDefaultMode()` 处理。供 `CoreToolScheduler` 使用。 + +- **`isToolEnabled(toolName: ToolName): boolean`** + 判断工具是否应被注册。内部通过 `deny` 规则集合和 `allow` 规则集合综合判断,仅基于 `permissions.*` 新格式规则。供 `Config.createToolRegistry()` 使用。 + +- **`isCommandAllowed(command: string): PermissionDecision`** + Shell 命令级权限检查,供 `shell-utils.ts` 中的 `checkCommandPermissions()` 调用,替代现有散乱的 `getCoreTools()` / `getExcludeTools()` 调用。 + +- **`listRules(): RuleWithSource[]`** + 返回所有生效规则(含来源 scope 信息),供 `/permissions` 对话框展示。来源标注为 `'system'` / `'user'` / `'workspace'` / `'session'`。 + +- **`addSessionAllowRule(rule: string): void`** + 在会话期间动态添加 allow 规则(内存中,不写入 settings 文件)。当用户在确认弹窗中点击"Always allow"时调用,替代现有的 `ToolConfirmationOutcome.ProceedAlways` 机制。 + +- **`addPersistentRule(ruleStr: string, type: 'allow' | 'ask' | 'deny', scope: SettingScope): void`** + 持久化写入规则到指定 scope 的 settings.json 文件,同时更新内存中的规则集。供 `/permissions` 对话框的"Add rule"操作调用。 + +- **`removeRule(ruleStr: string, type: 'allow' | 'ask' | 'deny', scope: SettingScope): void`** + 从指定 scope 的 settings.json 中删除规则,同时更新内存。供 `/permissions` 对话框的"Delete rule"操作调用。 + +- **`getDefaultMode(): ApprovalMode`** + 返回当前全局审批模式(`DEFAULT` / `AUTO_EDIT` / `YOLO` / `PLAN`),供 `CoreToolScheduler` 的回退逻辑使用。 + +--- + +## 配置迁移 + +`tools.core` / `tools.exclude` / `tools.allowed` 三个旧配置项在 Permission System 功能开发完成并发布后将**正式删除**,不再保留兼容逻辑。新版本启动时若检测到这些旧字段,会主动引导用户完成一键迁移。 + +### 旧配置映射规则 + +迁移逻辑需要将每个旧字段转换为等价的新格式规则: + +| 旧配置项 | 旧值示例 | 迁移为新字段 | 说明 | +| --------------- | ------------------------------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `tools.core` | `["read_file", "list_dir"]` | `permissions.allow: ["Tool(read_file)", "Tool(list_dir)"]` + `permissions.deny: ["Tool(*)"]` | 白名单模式:列出工具加入 allow,追加全量 deny 兜底 | +| `tools.exclude` | `["run_shell_command"]` | `permissions.deny: ["Tool(run_shell_command)"]` | 黑名单直接映射为 deny | +| `tools.allowed` | `["run_shell_command(git *)"]` | `permissions.allow: ["Tool(run_shell_command(git *))"]` | 免确认列表映射为 allow | + +> **`tools.core` 特殊处理**:由于旧白名单语义等价于"允许列出的工具 + 拒绝其余所有工具",迁移时须在 `permissions.deny` 末尾追加 `Tool(*)` 兜底规则。若用户 `permissions.deny` 中已存在 `Tool(*)`,不重复添加。 + +### 启动时迁移检测与提示 + +**触发条件**:应用启动、`Config.initialize()` 执行完毕后,`PermissionManager` 检测到以下任意条件成立: + +- `settings.tools.core` 非空数组 +- `settings.tools.exclude` 非空数组 +- `settings.tools.allowed` 非空数组 + +**交互流程**: + +1. 在 CLI 启动 banner 区域(首次 prompt 渲染之前)展示迁移提示,内容包括: + - 检测到哪些旧字段及其当前值 + - 对应会迁移成哪些新规则(展示预览) + - 影响哪个 settings 文件(user / workspace / local) +2. 询问用户是否立即迁移,提供三个选项: + - **`[Y] 立即迁移`**:执行迁移,写入新字段,删除旧字段,打印成功信息 + - **`[n] 跳过`**:本次启动不迁移,旧字段本次**不会生效**,下次启动继续提示 + - **`[?] 查看详情`**:打印完整的字段对照表,然后重新展示选项 + +**迁移写入逻辑**: + +迁移函数 `migrateLegacySettings(loadedSettings)` 实现以下步骤,按 scope(user / workspace / local)分别处理: + +1. 读取该 scope 下 `tools.core` / `tools.exclude` / `tools.allowed` 的原始值(未合并) +2. 按映射规则生成等价的 `permissions.allow` / `permissions.deny` 条目 +3. 调用 `LoadedSettings.setValue(scope, 'permissions.allow', [...existing, ...newAllow])` 追加新规则(避免覆盖该 scope 中已有的新格式规则) +4. 调用 `LoadedSettings.setValue(scope, 'permissions.deny', [...existing, ...newDeny])` 同上 +5. 调用 `LoadedSettings.setValue(scope, 'tools.core', undefined)` 删除旧字段 +6. 同样删除 `tools.exclude`、`tools.allowed` +7. 调用 `saveSettings(settingsFile)` 持久化 + +**CLI 参数的处理**:`--allowedTools` / `--disallowedTools` CLI 参数在 Permission System 完成后同步废弃,替换为 `--allow` / `--deny`,旧参数名在同一版本保留别名直至下一个 major 版本删除,不进入 settings 文件迁移流程。 + +### Settings Schema 同步清理 + +`tools.core` / `tools.exclude` / `tools.allowed` 字段在 `settingsSchema.ts` 中随 Permission System 一同**删除**。`LoadedSettings` 的类型定义、合并逻辑及相关单元测试同步清理。 + +--- + +## 改动清单 + +### 1. Settings Schema(`packages/cli/src/config/settingsSchema.ts`) + +**目标**:新增 `permissions` 顶层配置字段,并删除旧字段。 + +**方案**:在 `settingsSchema` 的 `tools` 同级位置新增 `permissions` 配置节,包含: + +- `permissions.allow`:array of strings,`MergeStrategy.UNION`(多层级数组合并) +- `permissions.ask`:array of strings,`MergeStrategy.UNION` +- `permissions.deny`:array of strings,`MergeStrategy.UNION` + +同步删除 `tools.core`、`tools.exclude`、`tools.allowed` 字段定义。 + +**合并策略**:与现有 `tools.exclude` 的 `MergeStrategy.UNION` 一致,多层级的 `permissions.*` 数组会被合并而非覆盖,低优先级 scope 的规则会追加到高优先级 scope 的规则后面。 + +### 2. 核心权限模块(新建 `packages/core/src/permissions/`) + +按上述模块结构说明创建全部文件。 + +`packages/core/src/index.ts` 中新增导出: + +``` +export { PermissionManager } from './permissions/index.js'; +export type { PermissionDecision, PermissionRule, RuleWithSource } from './permissions/index.js'; +``` + +### 3. Config 类(`packages/core/src/config/config.ts`) + +**目标**:将 `PermissionManager` 作为 `Config` 的托管实例,对齐 `SkillManager` 模式。 + +**改动点**: + +- 新增私有字段 `private permissionManager: PermissionManager | null = null` +- 在 `initialize()` 方法中(`skillManager` 初始化之后)实例化:`this.permissionManager = new PermissionManager(this)` +- 新增 getter:`getPermissionManager(): PermissionManager | null` +- `shutdown()` 中无需特殊处理(PermissionManager 无文件 watcher) +- 原有的 `getCoreTools()` / `getExcludeTools()` / `getAllowedTools()` 方法**删除**,所有调用方统一切换到 `PermissionManager` + +### 4. 工具注册(`packages/core/src/config/config.ts` - `createToolRegistry`) + +**目标**:工具注册时使用 `PermissionManager.isToolEnabled()` 替代现有的 `isToolEnabled()` 工具函数。 + +**方案**:`createToolRegistry()` 内部获取 `this.permissionManager`,调用其 `isToolEnabled(toolName)` 判断是否注册该工具。底层 `tool-utils.ts` 中的 `isToolEnabled()` 函数**保留**,作为 `PermissionManager` 内部的工具函数被调用,不对外破坏接口。 + +### 5. Shell 命令权限检查(`packages/core/src/utils/shell-utils.ts`) + +**目标**:`checkCommandPermissions()` 改为调用 `PermissionManager`,移除对 `config.getCoreTools()` / `config.getExcludeTools()` 的直接调用。 + +**方案**:函数内部通过 `config.getPermissionManager().isCommandAllowed(command)` 获得 `PermissionDecision`,并据此返回结果。原有对 `getExcludeTools()` / `getCoreTools()` 的调用全部删除。 + +### 6. CoreToolScheduler(`packages/core/src/core/coreToolScheduler.ts`) + +**目标**:权限决策逻辑集中到 `PermissionManager`,移除散落的 `getAllowedTools()` 调用。 + +**方案**:在工具调用确认流程中,替换原有逻辑: + +- **原逻辑**:取 `getAllowedTools()` 列表,调用 `doesToolInvocationMatch()` 判断是否自动通过 +- **新逻辑**:调用 `permissionManager.evaluate({ toolName, invocation })` 获取决策 + +三态决策处理: + +- `allow`:`setToolCallOutcome(ProceedAlways)`,自动通过 +- `deny`:直接设置 error 状态,返回拒绝消息 +- `ask` 或 `default`(且 defaultMode 不是 YOLO):进入用户确认流程 +- `default` 且 defaultMode 为 YOLO:自动通过 + +用户在确认弹窗选择"Always allow"时,调用 `permissionManager.addSessionAllowRule(rule)` 记录会话级规则。 + +### 7. ShellProcessor(`packages/cli/src/services/prompt-processors/shellProcessor.ts`) + +**目标**:移除对 `config.getAllowedTools()` 的直接调用,通过 `PermissionManager` 统一处理。 + +**方案**:`doesToolInvocationMatch()` 的调用替换为 `permissionManager.evaluate()` 调用,保持现有的 `sessionShellAllowlist` 逻辑不变(会话白名单通过 `addSessionAllowRule` 映射)。 + +### 8. `/permissions` 命令(`packages/cli/src/ui/commands/permissionsCommand.ts`) + +**目标**:命令触发时打开新的权限管理对话框,替代现有仅打开文件夹信任设置的 dialog。 + +**方案**:命令 action 返回 `{ type: 'dialog', dialog: 'permissions' }`(已有),新增对应的对话框组件处理此 dialog 类型。 + +### 9. Settings 迁移映射(`packages/cli/src/config/settings.ts`) + +**目标**:更新 V1→V2 的 `MIGRATION_MAP`,将旧的平铺键名映射移除。 + +**背景**:`settings.ts` 中存在 `MIGRATION_MAP`,记录了 V1(平铺格式)→ V2(嵌套格式)的键名映射,其中包含: + +``` +allowedTools: 'tools.allowed' +coreTools: 'tools.core' +excludeTools: 'tools.exclude' +``` + +**改动点**: + +- 从 `MIGRATION_MAP` 中删除 `allowedTools`、`coreTools`、`excludeTools` 三条映射 +- `needsMigration()` 和 `migrateSettings()` 中基于这三个键的逻辑随之清理 +- 同步更新 `settings.test.ts` 中相关迁移场景的测试用例 + +> **注意**:`settings.ts` 里的旧迁移逻辑处理的是格式层面(V1 平铺 → V2 嵌套),与本次 Permission System 的语义迁移(`tools.*` → `permissions.*`)不同。本次迁移逻辑由独立的 `migrateLegacySettings()` 函数承担,不耦合到已有 `migrateSettings()`。 + +### 10. 遥测(`packages/core/src/telemetry/types.ts`) + +**目标**:`SessionStartEvent` 中 `core_tools_enabled` 字段改为基于新权限规则。 + +**改动点**: + +- `core_tools_enabled` 字段原值为 `config.getCoreTools()` 的 join 结果 +- 替换为读取 `config.getPermissionManager()` 的 deny/allow 规则摘要,或改为记录 `permissions.deny` 规则数量 +- 相关测试文件(`loggers.test.ts`、`qwen-logger.test.ts`)中 mock 的 `getCoreTools()` 同步替换 + +### 11. NonInteractive 控制器(`packages/cli/src/nonInteractive/control/controllers/systemController.ts`) + +`systemController.ts` 中对 `config.excludeTools` 的直接引用,随 `Config` 类删除 `getExcludeTools()` 方法后,需改为通过 `config.getPermissionManager()` 获取等效决策。NonInteractive 场景下的 `coreTools`、`excludeTools`、`allowedTools` **对外参数接口保持不变**,内部实现切换到 `PermissionManager` 即可。 + +### 12. SDK API + +**TypeScript SDK(`packages/sdk-typescript/`)和 Java SDK(`packages/sdk-java/`)**: + +`coreTools`、`excludeTools`、`allowedTools` 三个参数**保持不变**,不做任何参数接口的改动。SDK 使用者传入的这些参数,在 CLI 内部由启动时的迁移流程或 `PermissionManager` 初始化时处理——即 CLI 启动参数层面仍接受 `--coreTools` / `--excludeTools` / `--allowedTools`,进入进程后由 `PermissionManager` 在初始化阶段将其转换为等价的 `permissions.allow` / `permissions.deny` 规则(内存中,不写入 settings 文件)。 + +> **注意**:`packages/core/src/skills/types.ts` 中的 `allowedTools?: string[]` 是 **Skills(QWEN.md frontmatter)** 的独立字段,用于限制 skill 可调用的工具,与权限系统无关,**不在本次改动范围内**。同样,`mcpServers..excludeTools` 是 MCP server 配置的工具过滤字段,**不在本次改动范围内**。 + +### 13. 国际化(i18n) + +**目标**:为新增 UI 文本添加多语言翻译条目。 + +**需要新增翻译的文件**: + +- `packages/cli/src/i18n/locales/en.js`(基准,其余语言参照翻译) +- `packages/cli/src/i18n/locales/zh.js` +- `packages/cli/src/i18n/locales/de.js` +- `packages/cli/src/i18n/locales/ja.js` +- `packages/cli/src/i18n/locales/pt.js` +- `packages/cli/src/i18n/locales/ru.js` + +**需要新增的 UI 文本分类**(在 `// Dialogs - Permissions` 区块下扩展): + +| 文本 key(英文原文) | 用途 | +| ---------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `Allow` / `Ask` / `Deny` / `Workspace` | Tab 标签 | +| `Add a new rule…` | 规则列表首行操作 | +| `Add allow permission rule` / `Add ask permission rule` / `Add deny permission rule` | 新增规则对话框标题 | +| `Permission rules are a tool name, optionally followed by a specifier in parentheses.` | 输入提示说明 | +| `Enter permission rule...` | 输入框 placeholder | +| `Where should this rule be saved?` | 保存位置选择提示 | +| `Project settings (local)` / `Project settings` / `User settings` | 保存位置选项 | +| `Saved in .qwen/settings.local.json` / `Checked in at .qwen/settings.json` / `Saved in at ~/.qwen/settings.json` | 保存位置说明 | +| `Any use of the {{tool}} tool` | 规则描述模板 | +| `{{tool}} commands starting with '{{prefix}}'` | 命令前缀规则描述 | +| `Delete allowed tool?` / `Delete ask rule?` / `Delete denied tool?` | 删除确认标题 | +| `Are you sure you want to delete this permission rule?` | 删除确认正文 | +| `From user settings` / `From project settings` / `From project settings (local)` | 规则来源标注 | +| `Add directory…` | Workspace Tab 操作 | +| `Add directory to workspace` | 新增目录对话框标题 | +| `Enter the path to the directory:` | 目录输入提示 | +| `Directory path...` | 目录输入框 placeholder | +| `Original working directory` | 初始目录标注 | +| 迁移提示相关文本 | 启动时迁移检测提示及三个操作选项 | + +**需要删除的翻译条目**:与 `tools.core` / `tools.exclude` / `tools.allowed` 对应的旧 UI 文本(如果存在)。 + +### 14. 用户文档与开发者文档 + +**需要更新的文档文件**: + +| 文件 | 改动内容 | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `docs/users/configuration/settings.md` | 删除 `tools.core`、`tools.exclude`、`tools.allowed` 的配置项说明行,新增 `permissions.allow`、`permissions.ask`、`permissions.deny` 说明 | +| `docs/developers/tools/shell.md` | 将 Shell 命令权限限制的示例从 `tools.core` / `tools.exclude` 改为 `permissions.deny` / `permissions.allow` 的等价写法 | +| `docs/developers/sdk-typescript.md` | 更新 SDK 选项表,删除 `coreTools`、`excludeTools`、`allowedTools`,新增 `permissions` 选项说明 | +| `docs/developers/sdk-java.md` | 同上,更新 Java SDK 选项说明 | + +**不需要改动的文档**: + +- `docs/users/features/mcp.md` 和 `docs/developers/tools/mcp-server.md` 中的 `excludeTools` 是 MCP server 级别的独立过滤配置,与权限系统无关,保持不变 + +--- + +## UI 实现 + +### 对话框整体结构 + +`/permissions` 命令触发后打开一个全屏交互式对话框,顶部有四个 Tab 页: + +``` +Permissions: [ Allow ] Ask Deny Workspace (←/→ or tab to cycle) +``` + +Tab 说明: + +- **Allow**:显示所有 allow 规则列表 +- **Ask**:显示所有 ask 规则列表 +- **Deny**:显示所有 deny 规则列表 +- **Workspace**:显示当前工作目录及附加目录 + +### Allow / Ask / Deny Tab + +每个 Tab 的布局: + +``` +Permissions: [ Allow ] Ask Deny Workspace + +Claude Code won't ask before using allowed tools. +(或对应 tab 的描述文字) + + ○ Search... + +› 1. Add a new rule… + 2. run_shell_command(git *) [来源:workspace settings] + 3. mcp__server [来源:user settings] + +Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel +``` + +**交互行为**: + +- 搜索框过滤规则列表 +- 选中"Add a new rule…"进入新增规则流程 +- 选中已有规则进入删除确认流程 + +### 新增规则流程 + +**步骤一**:输入规则字符串 + +``` +Add allow permission rule + +Permission rules are a tool name, optionally followed by a specifier in parentheses. +e.g., WebFetch or Bash(ls:*) + +┌─────────────────────────────────────────┐ +│ Enter permission rule... │ +└─────────────────────────────────────────┘ + +Enter to submit · Esc to cancel +``` + +**步骤二**:确认规则含义并选择保存位置 + +``` +Add allow permission rule + + WebFetch + Any use of the WebFetch tool + +Where should this rule be saved? +› 1. Project settings (local) Saved in .qwen/settings.local.json + 2. Project settings Checked in at .qwen/settings.json + 3. User settings Saved in at ~/.qwen/settings.json + +Enter to confirm · Esc to cancel +``` + +步骤二中实时展示规则的人类可读描述: + +- `Bash` → `Any use of the Bash tool` +- `Bash(git *)` → `Bash commands starting with 'git'` +- `WebFetch` → `Any use of the WebFetch tool` +- `read_file(./.env)` → `Reading the file .env` + +### 删除规则确认 + +``` +Delete allowed tool? + + mcp__pencil + Any use of the mcp__pencil tool + From user settings + +Are you sure you want to delete this permission rule? + +› 1. Yes + 2. No + +Esc to cancel +``` + +### Workspace Tab + +``` +Permissions: Allow Ask Deny [ Workspace ] + +Claude Code can read files in the workspace, and make edits when auto-accept edits is on. + + - /Users/mochi/code/qwen-code (Original working directory) +› 1. Add directory… + +Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel +``` + +**新增目录流程**: + +``` +Add directory to workspace + +Claude Code will be able to read files in this directory and make edits when auto-accept edits is on. + +Enter the path to the directory: + +┌─────────────────────────────────────────┐ +│ Directory path... │ +└─────────────────────────────────────────┘ + +Tab to complete · Enter to add · Esc to cancel +``` + +新增的目录持久化写入到 `permissions.additionalDirectories`(workspace settings),同时调用 `config.getWorkspaceContext()` 更新运行时工作目录范围。 + +### 新增 React 组件与 Hook + +**新增组件**: + +- `packages/cli/src/ui/components/PermissionsDialog.tsx`:完整的 `/permissions` 对话框,包含四个 Tab 的状态管理与渲染 +- `packages/cli/src/ui/components/AddPermissionRuleDialog.tsx`:新增规则的二步流程对话框 +- `packages/cli/src/ui/components/DeletePermissionRuleDialog.tsx`:删除规则确认对话框 +- `packages/cli/src/ui/components/AddWorkspaceDirectoryDialog.tsx`:新增工作目录对话框 + +**新增 Hook**: + +- `packages/cli/src/ui/hooks/usePermissionsDialog.ts`:管理 `/permissions` 对话框的开关状态(对齐 `useAgentsManagerDialog` 模式) +- `packages/cli/src/ui/hooks/usePermissionRules.ts`:从 `PermissionManager` 读取规则列表,提供新增/删除操作 + +**`AppContainer.tsx` 改动**: + +- 新增 `usePermissionsDialog` hook 调用 +- 将现有的 `isPermissionsDialogOpen` 状态(当前用于旧的文件夹信任对话框)迁移,新增 `PermissionsDialog` 组件的渲染条件 +- 在 `DialogManager` 中注册 `'permissions'` dialog 类型到新 `PermissionsDialog` 组件 + +--- + +## 数据流 + +``` +settings.json (各层级的 permissions.allow/ask/deny) + + CLI 参数 (--allow / --deny) + + 会话动态规则(用户确认弹窗选择 Always allow) + ↓ + PermissionManager(Config 内唯一实例) + ↙ ↓ ↘ +CoreToolScheduler shell-utils /permissions dialog +(evaluate) (isCommandAllowed) (listRules / addRule / removeRule) + ↓ + 工具注册(isToolEnabled) +``` + +--- + +## 实现顺序建议 + +1. **`packages/core/src/permissions/`**(types + rule-parser + permission-manager) +2. **`settingsSchema.ts`** 新增 `permissions` 字段 +3. **`Config`** 挂载 `PermissionManager` 实例 +4. **`createToolRegistry`** 切换到 `PermissionManager.isToolEnabled()` +5. **`shell-utils.ts`** 切换到 `PermissionManager.isCommandAllowed()` +6. **`CoreToolScheduler`** 切换到 `PermissionManager.evaluate()` +7. **`shellProcessor.ts`** 适配改动 +8. **UI 组件**(PermissionsDialog 及相关子组件) +9. **`AppContainer.tsx`** 接入新 dialog +10. **集成测试与单元测试** + +--- + +## 测试策略 + +### 单元测试 + +- `rule-parser.ts`:覆盖所有匹配规则的 glob 变体、路径规范、工具别名 +- `permission-manager.ts`: + - 三态决策的 first-match-wins 逻辑 + - `addSessionAllowRule` 的会话隔离性 + - `addPersistentRule` / `removeRule` 的文件写入逻辑 + +### 集成测试 + +- `CoreToolScheduler` 三态决策流程 +- Shell 命令 glob 匹配的安全边界(防止 shell 操作符绕过) +- 启动时检测到旧配置项时,迁移流程正确写入新字段并删除旧字段 diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index edca4aedd..180f91c30 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -225,6 +225,54 @@ If you are experiencing performance issues with file searching (e.g., with `@` c | `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes | | `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes | +> [!note] +> +> **Migrating from `tools.core` / `tools.exclude` / `tools.allowed`:** These legacy settings are automatically migrated to the new `permissions` format. See below. + +#### permissions + +The permissions system provides fine-grained control over which tools can run, which require confirmation, and which are blocked. Rules use the format `"ToolName"` or `"ToolName(specifier)"`. + +| Setting | Type | Description | Default | +| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- | ----------- | +| `permissions.allow` | array of strings | Rules for auto-approved tool calls (no confirmation needed). Merged across all scopes (user + project + system). | `undefined` | +| `permissions.ask` | array of strings | Rules for tool calls that require user confirmation. | `undefined` | +| `permissions.deny` | array of strings | Rules for blocked tool calls. Deny rules take highest priority. | `undefined` | + +**Rule syntax examples:** + +| Rule | Meaning | +| -------------------------------- | -------------------------------------------------------------- | +| `"Bash"` | All shell commands | +| `"Bash(git *)"` | Shell commands starting with `git` (word boundary: NOT `gitk`) | +| `"Bash(npm run build)"` | Exact command (also matches with trailing args) | +| `"Read"` | All file read tools (read_file, grep, glob, list_directory) | +| `"Read(./secrets/**)"` | Read files under `./secrets/` recursively | +| `"Edit(/src/**/*.ts)"` | Edit TypeScript files under project root `/src/` | +| `"WebFetch(domain:example.com)"` | Fetch from example.com and subdomains | +| `"mcp__puppeteer"` | All tools from the puppeteer MCP server | + +**Path pattern prefixes:** + +| Prefix | Meaning | Example | +| ------ | ------------------------------------- | -------------------------- | +| `//` | Absolute path from filesystem root | `//Users/alice/secrets/**` | +| `~/` | Relative to home directory | `~/Documents/*.pdf` | +| `/` | Relative to project root | `/src/**/*.ts` | +| `./` | Relative to current working directory | `./secrets/**` | + +**Example configuration:** + +```json +{ + "permissions": { + "allow": ["Bash(git *)", "Bash(npm *)"], + "ask": ["Edit"], + "deny": ["Bash(rm -rf *)", "Read(.env)"] + } +} +``` + #### mcp | Setting | Type | Description | Default | diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 48961cdca..a1927bb91 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -19,7 +19,6 @@ import { Storage, InputFormat, OutputFormat, - isToolEnabled, SessionService, ideContextStore, type ResumedSessionData, @@ -802,64 +801,87 @@ export async function loadCliConfig( // (fallback for edge cases where query/prompt is provided with TEXT output) interactive = false; } - // In non-interactive mode, exclude tools that require a prompt. - // However, if stream-json input is used, control can be requested via JSON messages, - // so tools should not be excluded in that case. - const extraExcludes: string[] = []; - const resolvedCoreTools = argv.coreTools || settings.tools?.core || []; - const resolvedAllowedTools = - argv.allowedTools || settings.tools?.allowed || []; - const isExplicitlyEnabled = (toolName: ToolName): boolean => { - if (resolvedCoreTools.length > 0) { - if (isToolEnabled(toolName, resolvedCoreTools, [])) { - return true; - } - } - if (resolvedAllowedTools.length > 0) { - if (isToolEnabled(toolName, resolvedAllowedTools, [])) { - return true; - } - } - return false; - }; - const excludeUnlessExplicit = (toolName: ToolName): void => { - if (!isExplicitlyEnabled(toolName)) { - extraExcludes.push(toolName); - } + // ── Unified permissions construction ───────────────────────────────────── + // All permission sources are merged here, before constructing Config. + // The resulting three arrays are the single source of truth that Config / + // PermissionManager will use. + // + // Sources (in order of precedence within each list): + // 1. settings.permissions.{allow,ask,deny} (persistent, merged by LoadedSettings) + // 2. argv.coreTools → allow (allowlist mode: only these tools are available) + // 3. argv.allowedTools → allow (auto-approve these tools/commands) + // 4. argv.excludeTools → deny (block these tools completely) + // 5. Non-interactive mode exclusions → deny (unless explicitly allowed above) + + // Start from settings-level rules. + // Read from both new `permissions` and legacy `tools` paths for compatibility. + const mergedAllow: string[] = [ + ...(settings.permissions?.allow ?? []), + ...(settings.tools?.core ?? []), + ...(settings.tools?.allowed ?? []), + ]; + const mergedAsk: string[] = [...(settings.permissions?.ask ?? [])]; + const mergedDeny: string[] = [ + ...(settings.permissions?.deny ?? []), + ...(settings.tools?.exclude ?? []), + ]; + + // argv.coreTools and argv.allowedTools both add allow rules. + for (const t of argv.coreTools ?? []) { + if (t && !mergedAllow.includes(t)) mergedAllow.push(t); + } + for (const t of argv.allowedTools ?? []) { + if (t && !mergedAllow.includes(t)) mergedAllow.push(t); + } + + // argv.excludeTools adds deny rules. + for (const t of argv.excludeTools ?? []) { + if (t && !mergedDeny.includes(t)) mergedDeny.push(t); + } + + // Helper: check if a tool is covered by any allow rule (tool-level, no specifier). + const isExplicitlyAllowed = (toolName: ToolName): boolean => { + const name = toolName as string; + return mergedAllow.some((rule) => { + const openParen = rule.indexOf('('); + const ruleName = + openParen === -1 ? rule.trim() : rule.substring(0, openParen).trim(); + return ruleName === name; + }); }; - // ACP mode check: must include both --acp (current) and --experimental-acp (deprecated). - // Without this check, edit, write_file, run_shell_command would be excluded in ACP mode. + // In non-interactive mode, tools that require a user prompt are denied unless + // the caller has explicitly allowed them. Stream-JSON input is excluded from + // this logic because approval can be sent programmatically via JSON messages. const isAcpMode = argv.acp || argv.experimentalAcp; if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) { + const denyUnlessAllowed = (toolName: ToolName): void => { + if (!isExplicitlyAllowed(toolName)) { + const name = toolName as string; + if (!mergedDeny.includes(name)) mergedDeny.push(name); + } + }; + switch (approvalMode) { case ApprovalMode.PLAN: case ApprovalMode.DEFAULT: - // In default non-interactive mode, all tools that require approval are excluded, - // unless explicitly enabled via coreTools/allowedTools. - excludeUnlessExplicit(ShellTool.Name as ToolName); - excludeUnlessExplicit(EditTool.Name as ToolName); - excludeUnlessExplicit(WriteFileTool.Name as ToolName); + // Deny all write/execute tools unless explicitly allowed. + denyUnlessAllowed(ShellTool.Name as ToolName); + denyUnlessAllowed(EditTool.Name as ToolName); + denyUnlessAllowed(WriteFileTool.Name as ToolName); break; case ApprovalMode.AUTO_EDIT: - // In auto-edit non-interactive mode, only tools that still require a prompt are excluded. - excludeUnlessExplicit(ShellTool.Name as ToolName); + // Only shell requires a prompt in auto-edit mode. + denyUnlessAllowed(ShellTool.Name as ToolName); break; case ApprovalMode.YOLO: - // No extra excludes for YOLO mode. + // No extra denials for YOLO mode. break; default: - // This should never happen due to validation earlier, but satisfies the linter break; } } - const excludeTools = mergeExcludeTools( - settings, - extraExcludes.length > 0 ? extraExcludes : undefined, - argv.excludeTools, - ); - let allowedMcpServers: Set | undefined; let excludedMcpServers: Set | undefined; if (argv.allowedMcpServerNames) { @@ -950,9 +972,16 @@ export async function loadCliConfig( importFormat: settings.context?.importFormat || 'tree', debugMode, question, + // Legacy fields – kept for backward compatibility with getExcludeTools() etc. coreTools: argv.coreTools || settings.tools?.core || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, - excludeTools, + excludeTools: mergedDeny, + // New unified permissions (PermissionManager source of truth). + permissions: { + allow: mergedAllow.length > 0 ? mergedAllow : undefined, + ask: mergedAsk.length > 0 ? mergedAsk : undefined, + deny: mergedDeny.length > 0 ? mergedDeny : undefined, + }, toolDiscoveryCommand: settings.tools?.discoveryCommand, toolCallCommand: settings.tools?.callCommand, mcpServerCommand: settings.mcp?.serverCommand, @@ -1058,16 +1087,3 @@ export async function loadCliConfig( return config; } - -function mergeExcludeTools( - settings: Settings, - extraExcludes?: string[] | undefined, - cliExcludeTools?: string[] | undefined, -): string[] { - const allExcludeTools = new Set([ - ...(cliExcludeTools || []), - ...(settings.tools?.exclude || []), - ...(extraExcludes || []), - ]); - return [...allExcludeTools]; -} diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index e261cc723..2cd2799d5 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -124,6 +124,74 @@ const MIGRATION_MAP: Record = { tavilyApiKey: 'advanced.tavilyApiKey', }; +/** + * Migrate legacy tool permission settings (tools.core / tools.allowed / tools.exclude) + * to the new permissions.allow / permissions.ask / permissions.deny format. + * + * Conversion rules: + * tools.allowed → permissions.allow (bypass confirmation) + * tools.exclude → permissions.deny (block tools) + * tools.core → permissions.allow (only listed tools enabled) + * + permissions.deny with a wildcard deny-all if needed + * + * Returns the updated settings object, or null if no migration is needed. + */ +export function migrateLegacyPermissions( + settings: Record, +): Record | null { + const tools = settings['tools'] as Record | undefined; + if (!tools) return null; + + const hasLegacy = + Array.isArray(tools['core']) || + Array.isArray(tools['allowed']) || + Array.isArray(tools['exclude']); + + if (!hasLegacy) return null; + + const result = structuredClone(settings) as Record; + const resultTools = result['tools'] as Record; + const permissions = (result['permissions'] as Record) ?? {}; + result['permissions'] = permissions; + + const mergeInto = (key: string, items: string[]) => { + const existing = Array.isArray(permissions[key]) + ? (permissions[key] as string[]) + : []; + const merged = Array.from(new Set([...existing, ...items])); + permissions[key] = merged; + }; + + // tools.allowed → permissions.allow + if (Array.isArray(resultTools['allowed'])) { + mergeInto('allow', resultTools['allowed'] as string[]); + delete resultTools['allowed']; + } + + // tools.exclude → permissions.deny + if (Array.isArray(resultTools['exclude'])) { + mergeInto('deny', resultTools['exclude'] as string[]); + delete resultTools['exclude']; + } + + // tools.core → permissions.allow (explicit enables) + // IMPORTANT: tools.core has whitelist semantics: "only these tools can run". + // To preserve this, we also add deny rules for all tools NOT in the list. + // A wildcard deny-all followed by specific allows achieves this because + // allow rules take precedence over the catch-all deny in the evaluation order: + // deny = [everything not listed], allow = [listed tools] + // However, since our priority is deny > allow, we cannot use a blanket deny. + // Instead we just migrate to allow (auto-approve) and let the coreTools + // semantics continue to work through the Config.getCoreTools() path until + // the old API is fully removed. + if (Array.isArray(resultTools['core'])) { + mergeInto('allow', resultTools['core'] as string[]); + delete resultTools['core']; + } + + return result; +} + // Settings that need boolean inversion during migration (V1 -> V3) // Old negative naming -> new positive naming with inverted value const INVERTED_BOOLEAN_MIGRATIONS: Record = { diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index cfde449ca..c4ad800e2 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -181,9 +181,7 @@ describe('SettingsSchema', () => { expect(getSettingsSchema().security.properties.auth.showInDialog).toBe( false, ); - expect(getSettingsSchema().tools.properties.core.showInDialog).toBe( - false, - ); + expect(getSettingsSchema().permissions.showInDialog).toBe(false); expect(getSettingsSchema().mcpServers.showInDialog).toBe(false); expect(getSettingsSchema().telemetry.showInDialog).toBe(false); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fd6c3e85b..182db99b4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -789,6 +789,55 @@ const SETTINGS_SCHEMA = { }, }, + permissions: { + type: 'object', + label: 'Permissions', + category: 'Tools', + requiresRestart: true, + default: {}, + description: + 'Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.', + showInDialog: false, + properties: { + allow: { + type: 'array', + label: 'Allow Rules', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Tools or commands that are auto-approved without confirmation. ' + + 'Examples: "ShellTool", "Bash(git *)", "ReadFileTool".', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + ask: { + type: 'array', + label: 'Ask Rules', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Tools or commands that always require user confirmation. ' + + 'Takes precedence over allow rules.', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + deny: { + type: 'array', + label: 'Deny Rules', + category: 'Tools', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Tools or commands that are always blocked. Highest priority rule. ' + + 'Examples: "ShellTool", "Bash(rm -rf *)".', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + tools: { type: 'object', label: 'Tools', @@ -848,32 +897,33 @@ const SETTINGS_SCHEMA = { }, }, }, + // Legacy tool permission fields – kept for backward compatibility. + // Use permissions.{allow,ask,deny} instead. core: { type: 'array', - label: 'Core Tools', + label: 'Core Tools (deprecated)', category: 'Tools', requiresRestart: true, default: undefined as string[] | undefined, - description: 'Paths to core tool definitions.', + description: 'Deprecated. Use permissions.allow instead.', showInDialog: false, }, allowed: { type: 'array', - label: 'Allowed Tools', + label: 'Allowed Tools (deprecated)', category: 'Advanced', requiresRestart: true, default: undefined as string[] | undefined, - description: - 'A list of tool names that will bypass the confirmation dialog.', + description: 'Deprecated. Use permissions.allow instead.', showInDialog: false, }, exclude: { type: 'array', - label: 'Exclude Tools', + label: 'Exclude Tools (deprecated)', category: 'Tools', requiresRestart: true, default: undefined as string[] | undefined, - description: 'Tool names to exclude from discovery.', + description: 'Deprecated. Use permissions.deny instead.', showInDialog: false, mergeStrategy: MergeStrategy.UNION, }, diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 7d4f50421..193b398db 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -37,12 +37,12 @@ vi.mock('../ui/commands/ideCommand.js', async () => { vi.mock('../ui/commands/restoreCommand.js', () => ({ restoreCommand: vi.fn(), })); -vi.mock('../ui/commands/permissionsCommand.js', async () => { +vi.mock('../ui/commands/trustCommand.js', async () => { const { CommandKind } = await import('../ui/commands/types.js'); return { - permissionsCommand: { - name: 'permissions', - description: 'Permissions command', + trustCommand: { + name: 'trust', + description: 'Trust command', kind: CommandKind.BUILT_IN, }, }; @@ -162,19 +162,19 @@ describe('BuiltinCommandLoader', () => { expect(modelCmd).toBeDefined(); }); - it('should include permissions command when folder trust is enabled', async () => { + it('should include trust command when folder trust is enabled', async () => { const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); - const permissionsCmd = commands.find((c) => c.name === 'permissions'); - expect(permissionsCmd).toBeDefined(); + const trustCmd = commands.find((c) => c.name === 'trust'); + expect(trustCmd).toBeDefined(); }); - it('should exclude permissions command when folder trust is disabled', async () => { + it('should exclude trust command when folder trust is disabled', async () => { (mockConfig.getFolderTrust as Mock).mockReturnValue(false); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); - const permissionsCmd = commands.find((c) => c.name === 'permissions'); - expect(permissionsCmd).toBeUndefined(); + const trustCmd = commands.find((c) => c.name === 'trust'); + expect(trustCmd).toBeUndefined(); }); it('should always include modelCommand', async () => { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index cda06daad..fe28d6e41 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -27,7 +27,7 @@ import { languageCommand } from '../ui/commands/languageCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; -import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; +import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; @@ -78,7 +78,7 @@ export class BuiltinCommandLoader implements ICommandLoader { mcpCommand, memoryCommand, modelCommand, - ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), + ...(this.config?.getFolderTrust() ? [trustCommand] : []), quitCommand, restoreCommand(this.config), resumeCommand, diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 151faf324..68ca60656 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -73,6 +73,8 @@ describe('ShellProcessor', () => { getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), getShellExecutionConfig: vi.fn().mockReturnValue({}), getAllowedTools: vi.fn().mockReturnValue([]), + // Default: no permission manager (tests that need one set it explicitly) + getPermissionManager: vi.fn().mockReturnValue(null), }; context = createMockCommandContext({ @@ -206,9 +208,11 @@ describe('ShellProcessor', () => { allAllowed: false, disallowedCommands: ['rm -rf /'], }); - (mockConfig.getAllowedTools as Mock).mockReturnValue([ - 'ShellTool(rm -rf /)', - ]); + // Simulate allowedTools being pre-merged into permissionsAllow by Config, + // so PermissionManager returns 'allow' for this command. + (mockConfig.getPermissionManager as Mock).mockReturnValue({ + isCommandAllowed: (_cmd: string) => 'allow', + }); mockShellExecute.mockReturnValue({ result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }), }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 2a6df7161..d50cf0118 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -7,13 +7,11 @@ import { ApprovalMode, checkCommandPermissions, - doesToolInvocationMatch, escapeShellArg, getShellConfiguration, ShellExecutionService, flatMapTextParts, } from '@qwen-code/qwen-code-core'; -import type { AnyToolInvocation } from '@qwen-code/qwen-code-core'; import type { CommandContext } from '../../ui/commands/types.js'; import type { IPromptProcessor, PromptPipelineContent } from './types.js'; @@ -126,15 +124,12 @@ export class ShellProcessor implements IPromptProcessor { // Security check on the final, escaped command string. const { allAllowed, disallowedCommands, blockReason, isHardDenial } = checkCommandPermissions(command, config, sessionShellAllowlist); - const allowedTools = config.getAllowedTools() || []; - const invocation = { - params: { command }, - } as AnyToolInvocation; - const isAllowedBySettings = doesToolInvocationMatch( - 'run_shell_command', - invocation, - allowedTools, - ); + + // Determine if this command is explicitly auto-approved via PermissionManager + const pm = config.getPermissionManager?.(); + const isAllowedBySettings = pm + ? pm.isCommandAllowed(command) === 'allow' + : false; if (!allAllowed) { if (isHardDenial) { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 781aab375..668ad2c1c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -234,15 +234,9 @@ export const AppContainer = (props: AppContainerProps) => { const { codingPlanUpdateRequest, dismissCodingPlanUpdate } = useCodingPlanUpdates(settings, config, historyManager.addItem); - const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); - const openPermissionsDialog = useCallback( - () => setPermissionsDialogOpen(true), - [], - ); - const closePermissionsDialog = useCallback( - () => setPermissionsDialogOpen(false), - [], - ); + const [isTrustDialogOpen, setTrustDialogOpen] = useState(false); + const openTrustDialog = useCallback(() => setTrustDialogOpen(true), []); + const closeTrustDialog = useCallback(() => setTrustDialogOpen(false), []); // Helper to determine the current model (polled, since Config has no model-change event). const getCurrentModel = useCallback(() => config.getModel(), [config]); @@ -501,7 +495,7 @@ export const AppContainer = (props: AppContainerProps) => { openEditorDialog, openSettingsDialog, openModelDialog, - openPermissionsDialog, + openTrustDialog, openApprovalModeDialog, quit: (messages: HistoryItem[]) => { setQuittingMessages(messages); @@ -525,7 +519,7 @@ export const AppContainer = (props: AppContainerProps) => { openModelDialog, setDebugMessage, dispatchExtensionStateUpdate, - openPermissionsDialog, + openTrustDialog, openApprovalModeDialog, addConfirmUpdateExtensionRequest, openSubagentCreateDialog, @@ -1292,7 +1286,7 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || - isPermissionsDialogOpen || + isTrustDialogOpen || isAuthDialogOpen || isAuthenticating || isEditorDialogOpen || @@ -1340,7 +1334,7 @@ export const AppContainer = (props: AppContainerProps) => { quittingMessages, isSettingsDialogOpen, isModelDialogOpen, - isPermissionsDialogOpen, + isTrustDialogOpen, isApprovalModeDialogOpen, isResumeDialogOpen, slashCommands, @@ -1429,7 +1423,7 @@ export const AppContainer = (props: AppContainerProps) => { quittingMessages, isSettingsDialogOpen, isModelDialogOpen, - isPermissionsDialogOpen, + isTrustDialogOpen, isApprovalModeDialogOpen, isResumeDialogOpen, slashCommands, @@ -1522,7 +1516,7 @@ export const AppContainer = (props: AppContainerProps) => { closeSettingsDialog, closeModelDialog, dismissCodingPlanUpdate, - closePermissionsDialog, + closeTrustDialog, setShellModeActive, vimHandleInput, handleIdePromptComplete, @@ -1567,7 +1561,7 @@ export const AppContainer = (props: AppContainerProps) => { closeSettingsDialog, closeModelDialog, dismissCodingPlanUpdate, - closePermissionsDialog, + closeTrustDialog, setShellModeActive, vimHandleInput, handleIdePromptComplete, diff --git a/packages/cli/src/ui/commands/permissionsCommand.test.ts b/packages/cli/src/ui/commands/trustCommand.test.ts similarity index 55% rename from packages/cli/src/ui/commands/permissionsCommand.test.ts rename to packages/cli/src/ui/commands/trustCommand.test.ts index f51e7c3df..dff3e5750 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.test.ts +++ b/packages/cli/src/ui/commands/trustCommand.test.ts @@ -5,11 +5,11 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { permissionsCommand } from './permissionsCommand.js'; +import { trustCommand } from './trustCommand.js'; import { type CommandContext, CommandKind } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -describe('permissionsCommand', () => { +describe('trustCommand', () => { let mockContext: CommandContext; beforeEach(() => { @@ -17,19 +17,19 @@ describe('permissionsCommand', () => { }); it('should have the correct name and description', () => { - expect(permissionsCommand.name).toBe('permissions'); - expect(permissionsCommand.description).toBe('Manage folder trust settings'); + expect(trustCommand.name).toBe('trust'); + expect(trustCommand.description).toBe('Manage folder trust settings'); }); it('should be a built-in command', () => { - expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN); + expect(trustCommand.kind).toBe(CommandKind.BUILT_IN); }); - it('should return an action to open the permissions dialog', () => { - const actionResult = permissionsCommand.action?.(mockContext, ''); + it('should return an action to open the trust dialog', () => { + const actionResult = trustCommand.action?.(mockContext, ''); expect(actionResult).toEqual({ type: 'dialog', - dialog: 'permissions', + dialog: 'trust', }); }); }); diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/trustCommand.ts similarity index 80% rename from packages/cli/src/ui/commands/permissionsCommand.ts rename to packages/cli/src/ui/commands/trustCommand.ts index 2b6a7c344..9fa566db2 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.ts +++ b/packages/cli/src/ui/commands/trustCommand.ts @@ -8,14 +8,14 @@ import type { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; -export const permissionsCommand: SlashCommand = { - name: 'permissions', +export const trustCommand: SlashCommand = { + name: 'trust', get description() { return t('Manage folder trust settings'); }, kind: CommandKind.BUILT_IN, action: (): OpenDialogActionReturn => ({ type: 'dialog', - dialog: 'permissions', + dialog: 'trust', }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 90330e988..ffbe9281c 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -146,7 +146,7 @@ export interface OpenDialogActionReturn { | 'model' | 'subagent_create' | 'subagent_list' - | 'permissions' + | 'trust' | 'approval-mode' | 'resume'; } diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c79e91119..2f62dd082 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -18,7 +18,7 @@ import { SettingsDialog } from './SettingsDialog.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; -import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; +import { TrustDialog } from './TrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; import { ApprovalModeDialog } from './ApprovalModeDialog.js'; import { theme } from '../semantic-colors.js'; @@ -265,12 +265,9 @@ export const DialogManager = ({ ); } } - if (uiState.isPermissionsDialogOpen) { + if (uiState.isTrustDialogOpen) { return ( - + ); } diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/TrustDialog.test.tsx similarity index 83% rename from packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx rename to packages/cli/src/ui/components/TrustDialog.test.tsx index 15d6948d8..6ca6133dc 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/TrustDialog.test.tsx @@ -9,13 +9,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; -import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; +import { TrustDialog } from './TrustDialog.js'; import { TrustLevel } from '../../config/trustedFolders.js'; import { waitFor, act } from '@testing-library/react'; import * as processUtils from '../../utils/processUtils.js'; -import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js'; +import { useTrustModify } from '../hooks/useTrustModify.js'; -// Hoist mocks for dependencies of the usePermissionsModifyTrust hook +// Hoist mocks for dependencies of the useTrustModify hook const mockedCwd = vi.hoisted(() => vi.fn()); const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn()); const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); @@ -39,16 +39,16 @@ vi.mock('../../config/trustedFolders.js', () => ({ }, })); -vi.mock('../hooks/usePermissionsModifyTrust.js'); +vi.mock('../hooks/useTrustModify.js'); -describe('PermissionsModifyTrustDialog', () => { +describe('TrustDialog', () => { let mockUpdateTrustLevel: Mock; let mockCommitTrustLevelChange: Mock; beforeEach(() => { mockUpdateTrustLevel = vi.fn(); mockCommitTrustLevelChange = vi.fn(); - vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + vi.mocked(useTrustModify).mockReturnValue({ cwd: '/test/dir', currentTrustLevel: TrustLevel.DO_NOT_TRUST, isInheritedTrustFromParent: false, @@ -66,7 +66,7 @@ describe('PermissionsModifyTrustDialog', () => { it('should render the main dialog with current trust level', async () => { const { lastFrame } = renderWithProviders( - , + , ); await waitFor(() => { @@ -77,7 +77,7 @@ describe('PermissionsModifyTrustDialog', () => { }); it('should display the inherited trust note from parent', async () => { - vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + vi.mocked(useTrustModify).mockReturnValue({ cwd: '/test/dir', currentTrustLevel: TrustLevel.DO_NOT_TRUST, isInheritedTrustFromParent: true, @@ -88,7 +88,7 @@ describe('PermissionsModifyTrustDialog', () => { isFolderTrustEnabled: true, }); const { lastFrame } = renderWithProviders( - , + , ); await waitFor(() => { @@ -99,7 +99,7 @@ describe('PermissionsModifyTrustDialog', () => { }); it('should display the inherited trust note from IDE', async () => { - vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + vi.mocked(useTrustModify).mockReturnValue({ cwd: '/test/dir', currentTrustLevel: TrustLevel.DO_NOT_TRUST, isInheritedTrustFromParent: false, @@ -110,7 +110,7 @@ describe('PermissionsModifyTrustDialog', () => { isFolderTrustEnabled: true, }); const { lastFrame } = renderWithProviders( - , + , ); await waitFor(() => { @@ -123,7 +123,7 @@ describe('PermissionsModifyTrustDialog', () => { it('should call onExit when escape is pressed', async () => { const onExit = vi.fn(); const { stdin, lastFrame } = renderWithProviders( - , + , ); await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); @@ -141,7 +141,7 @@ describe('PermissionsModifyTrustDialog', () => { const mockRelaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + vi.mocked(useTrustModify).mockReturnValue({ cwd: '/test/dir', currentTrustLevel: TrustLevel.DO_NOT_TRUST, isInheritedTrustFromParent: false, @@ -154,7 +154,7 @@ describe('PermissionsModifyTrustDialog', () => { const onExit = vi.fn(); const { stdin, lastFrame } = renderWithProviders( - , + , ); await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); @@ -171,7 +171,7 @@ describe('PermissionsModifyTrustDialog', () => { }); it('should not commit when escape is pressed during restart prompt', async () => { - vi.mocked(usePermissionsModifyTrust).mockReturnValue({ + vi.mocked(useTrustModify).mockReturnValue({ cwd: '/test/dir', currentTrustLevel: TrustLevel.DO_NOT_TRUST, isInheritedTrustFromParent: false, @@ -184,7 +184,7 @@ describe('PermissionsModifyTrustDialog', () => { const onExit = vi.fn(); const { stdin, lastFrame } = renderWithProviders( - , + , ); await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx b/packages/cli/src/ui/components/TrustDialog.tsx similarity index 92% rename from packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx rename to packages/cli/src/ui/components/TrustDialog.tsx index dfed5ba42..ed2f202a8 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx +++ b/packages/cli/src/ui/components/TrustDialog.tsx @@ -8,13 +8,13 @@ import { Box, Text } from 'ink'; import type React from 'react'; import { TrustLevel } from '../../config/trustedFolders.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js'; +import { useTrustModify } from '../hooks/useTrustModify.js'; import { theme } from '../semantic-colors.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { relaunchApp } from '../../utils/processUtils.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; -interface PermissionsModifyTrustDialogProps { +interface TrustDialogProps { onExit: () => void; addItem: UseHistoryManagerReturn['addItem']; } @@ -37,10 +37,10 @@ const TRUST_LEVEL_ITEMS = [ }, ]; -export function PermissionsModifyTrustDialog({ +export function TrustDialog({ onExit, addItem, -}: PermissionsModifyTrustDialogProps): React.JSX.Element { +}: TrustDialogProps): React.JSX.Element { const { cwd, currentTrustLevel, @@ -49,7 +49,7 @@ export function PermissionsModifyTrustDialog({ needsRestart, updateTrustLevel, commitTrustLevelChange, - } = usePermissionsModifyTrust(onExit, addItem); + } = useTrustModify(onExit, addItem); useKeypress( (key) => { diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index af15e72b6..f4e67f208 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -55,7 +55,7 @@ export interface UIActions { closeSettingsDialog: () => void; closeModelDialog: () => void; dismissCodingPlanUpdate: () => void; - closePermissionsDialog: () => void; + closeTrustDialog: () => void; setShellModeActive: (value: boolean) => void; vimHandleInput: (key: Key) => boolean; handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 9d1a21e83..386d9bba3 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -52,7 +52,7 @@ export interface UIState { quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; isModelDialogOpen: boolean; - isPermissionsDialogOpen: boolean; + isTrustDialogOpen: boolean; isApprovalModeDialogOpen: boolean; isResumeDialogOpen: boolean; slashCommands: readonly SlashCommand[]; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index c48653970..472f4508e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -156,7 +156,7 @@ describe('useSlashCommandProcessor', () => { openEditorDialog: vi.fn(), openSettingsDialog: vi.fn(), openModelDialog: mockOpenModelDialog, - openPermissionsDialog: vi.fn(), + openTrustDialog: vi.fn(), openApprovalModeDialog: vi.fn(), openResumeDialog: vi.fn(), quit: mockSetQuittingMessages, @@ -929,7 +929,7 @@ describe('useSlashCommandProcessor', () => { openEditorDialog: vi.fn(), openSettingsDialog: vi.fn(), openModelDialog: vi.fn(), - openPermissionsDialog: vi.fn(), + openTrustDialog: vi.fn(), openApprovalModeDialog: vi.fn(), openResumeDialog: vi.fn(), quit: mockSetQuittingMessages, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 80c6bec35..9694b05e2 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -69,7 +69,7 @@ interface SlashCommandProcessorActions { openEditorDialog: () => void; openSettingsDialog: () => void; openModelDialog: () => void; - openPermissionsDialog: () => void; + openTrustDialog: () => void; openApprovalModeDialog: () => void; openResumeDialog: () => void; quit: (messages: HistoryItem[]) => void; @@ -467,8 +467,8 @@ export const useSlashCommandProcessor = ( case 'model': actions.openModelDialog(); return { type: 'handled' }; - case 'permissions': - actions.openPermissionsDialog(); + case 'trust': + actions.openTrustDialog(); return { type: 'handled' }; case 'subagent_create': actions.openSubagentCreateDialog(); diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/useTrustModify.test.ts similarity index 91% rename from packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts rename to packages/cli/src/ui/hooks/useTrustModify.test.ts index 519752e82..c73ed0aab 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts +++ b/packages/cli/src/ui/hooks/useTrustModify.test.ts @@ -16,7 +16,7 @@ import { type Mock, } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -import { usePermissionsModifyTrust } from './usePermissionsModifyTrust.js'; +import { useTrustModify } from './useTrustModify.js'; import { TrustLevel } from '../../config/trustedFolders.js'; import type { LoadedSettings } from '../../config/settings.js'; import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; @@ -46,7 +46,7 @@ vi.mock('../contexts/SettingsContext.js', () => ({ useSettings: mockedUseSettings, })); -describe('usePermissionsModifyTrust', () => { +describe('useTrustModify', () => { let mockOnExit: Mock; let mockAddItem: Mock; @@ -84,7 +84,7 @@ describe('usePermissionsModifyTrust', () => { }); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + useTrustModify(mockOnExit, mockAddItem), ); expect(result.current.currentTrustLevel).toBe(TrustLevel.TRUST_FOLDER); @@ -101,7 +101,7 @@ describe('usePermissionsModifyTrust', () => { }); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + useTrustModify(mockOnExit, mockAddItem), ); expect(result.current.isInheritedTrustFromParent).toBe(true); @@ -118,7 +118,7 @@ describe('usePermissionsModifyTrust', () => { }); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + useTrustModify(mockOnExit, mockAddItem), ); expect(result.current.isInheritedTrustFromIde).toBe(true); @@ -137,7 +137,7 @@ describe('usePermissionsModifyTrust', () => { .mockReturnValueOnce({ isTrusted: true, source: 'file' }); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + useTrustModify(mockOnExit, mockAddItem), ); act(() => { @@ -161,7 +161,7 @@ describe('usePermissionsModifyTrust', () => { }); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + useTrustModify(mockOnExit, mockAddItem), ); act(() => { @@ -188,7 +188,7 @@ describe('usePermissionsModifyTrust', () => { .mockReturnValueOnce({ isTrusted: true, source: 'file' }); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + useTrustModify(mockOnExit, mockAddItem), ); act(() => { @@ -218,7 +218,7 @@ describe('usePermissionsModifyTrust', () => { }); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + useTrustModify(mockOnExit, mockAddItem), ); act(() => { @@ -245,7 +245,7 @@ describe('usePermissionsModifyTrust', () => { }); const { result } = renderHook(() => - usePermissionsModifyTrust(mockOnExit, mockAddItem), + useTrustModify(mockOnExit, mockAddItem), ); act(() => { diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts b/packages/cli/src/ui/hooks/useTrustModify.ts similarity index 98% rename from packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts rename to packages/cli/src/ui/hooks/useTrustModify.ts index f5a10ff38..fa403f61a 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.ts +++ b/packages/cli/src/ui/hooks/useTrustModify.ts @@ -42,7 +42,7 @@ function getInitialTrustState( }; } -export const usePermissionsModifyTrust = ( +export const useTrustModify = ( onExit: () => void, addItem: UseHistoryManagerReturn['addItem'], ) => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 98b72c9c2..c2b0d1fea 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -68,6 +68,7 @@ import { ideContextStore } from '../ide/ideContext.js'; import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; import { SkillManager } from '../skills/skill-manager.js'; +import { PermissionManager } from '../permissions/permission-manager.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; import type { SubagentConfig } from '../subagents/types.js'; import { @@ -289,9 +290,18 @@ export interface ConfigParameters { debugMode: boolean; includePartialMessages?: boolean; question?: string; + /** @deprecated Use `permissions.allow` instead. Migrated automatically. */ coreTools?: string[]; + /** @deprecated Use `permissions.allow` instead. Migrated automatically. */ allowedTools?: string[]; + /** @deprecated Use `permissions.deny` instead. Migrated automatically. */ excludeTools?: string[]; + /** Merged permission rules from all sources (settings + CLI args). */ + permissions?: { + allow?: string[]; + ask?: string[]; + deny?: string[]; + }; toolDiscoveryCommand?: string; toolCallCommand?: string; mcpServerCommand?: string; @@ -420,6 +430,7 @@ export class Config { private subagentManager!: SubagentManager; private extensionManager!: ExtensionManager; private skillManager: SkillManager | null = null; + private permissionManager: PermissionManager | null = null; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfigSources: ContentGeneratorConfigSources = {}; @@ -439,6 +450,9 @@ export class Config { private readonly coreTools: string[] | undefined; private readonly allowedTools: string[] | undefined; private readonly excludeTools: string[] | undefined; + private readonly permissionsAllow: string[] | undefined; + private readonly permissionsAsk: string[] | undefined; + private readonly permissionsDeny: string[] | undefined; private readonly toolDiscoveryCommand: string | undefined; private readonly toolCallCommand: string | undefined; private readonly mcpServerCommand: string | undefined; @@ -544,6 +558,9 @@ export class Config { this.coreTools = params.coreTools; this.allowedTools = params.allowedTools; this.excludeTools = params.excludeTools; + this.permissionsAllow = params.permissions?.allow; + this.permissionsAsk = params.permissions?.ask; + this.permissionsDeny = params.permissions?.deny; this.toolDiscoveryCommand = params.toolDiscoveryCommand; this.toolCallCommand = params.toolCallCommand; this.mcpServerCommand = params.mcpServerCommand; @@ -701,6 +718,10 @@ export class Config { await this.skillManager.startWatching(); this.debugLogger.debug('Skill manager initialized'); + this.permissionManager = new PermissionManager(this); + this.permissionManager.initialize(); + this.debugLogger.debug('Permission manager initialized'); + // Load session subagents if they were provided before initialization if (this.sessionSubagents.length > 0) { this.subagentManager.loadSessionSubagents(this.sessionSubagents); @@ -1073,6 +1094,10 @@ export class Config { return this.targetDir; } + getCwd(): string { + return this.targetDir; + } + getWorkspaceContext(): WorkspaceContext { return this.workspaceContext; } @@ -1115,18 +1140,69 @@ export class Config { return this.question; } + /** @deprecated Use getPermissionsAllow() instead. */ getCoreTools(): string[] | undefined { return this.coreTools; } + /** @deprecated Use getPermissionsAllow() instead. */ getAllowedTools(): string[] | undefined { return this.allowedTools; } + /** @deprecated Use getPermissionsDeny() instead. */ getExcludeTools(): string[] | undefined { return this.excludeTools; } + /** + * Returns the merged allow-rules for PermissionManager. + * + * This merges all sources so that PermissionManager receives a single, + * authoritative list: + * - settings.permissions.allow (persistent rules from all scopes) + * - coreTools param (SDK / argv allowlist mode: only these tools run) + * - allowedTools param (SDK / argv auto-approve list) + * + * CLI callers (loadCliConfig) already pre-merge argv into permissionsAllow + * before constructing Config, so those fields will be empty for CLI usage. + * SDK callers construct Config directly and rely on coreTools/allowedTools. + */ + getPermissionsAllow(): string[] | undefined { + const base = this.permissionsAllow ?? []; + const sdkAllow = [...(this.coreTools ?? []), ...(this.allowedTools ?? [])]; + if (sdkAllow.length === 0) return base.length > 0 ? base : undefined; + const merged = [...base]; + for (const t of sdkAllow) { + if (t && !merged.includes(t)) merged.push(t); + } + return merged; + } + + getPermissionsAsk(): string[] | undefined { + return this.permissionsAsk; + } + + /** + * Returns the merged deny-rules for PermissionManager. + * + * Merges: + * - settings.permissions.deny (persistent rules from all scopes) + * - excludeTools param (SDK / argv blocklist) + * + * CLI callers pre-merge argv.excludeTools into permissionsDeny. + */ + getPermissionsDeny(): string[] | undefined { + const base = this.permissionsDeny ?? []; + const sdkDeny = this.excludeTools ?? []; + if (sdkDeny.length === 0) return base.length > 0 ? base : undefined; + const merged = [...base]; + for (const t of sdkDeny) { + if (t && !merged.includes(t)) merged.push(t); + } + return merged; + } + getToolDiscoveryCommand(): string | undefined { return this.toolDiscoveryCommand; } @@ -1642,6 +1718,10 @@ export class Config { return this.skillManager; } + getPermissionManager(): PermissionManager | null { + return this.permissionManager; + } + async createToolRegistry( sendSdkMcpMessage?: SendSdkMcpMessage, ): Promise { @@ -1669,7 +1749,20 @@ export class Config { return; } - if (isToolEnabled(toolName, coreToolsConfig, excludeToolsConfig)) { + // Two-layer check: legacy coreTools/excludeTools whitelist + PM deny rules. + // Legacy isToolEnabled() preserves the whitelist semantic where coreTools + // acts as a strict allowlist (only listed tools are registered). + // PM.isToolEnabled() handles deny rules from the new permissions system. + const legacyEnabled = isToolEnabled( + toolName, + coreToolsConfig, + excludeToolsConfig, + ); + const pmEnabled = this.permissionManager + ? this.permissionManager.isToolEnabled(toolName) + : true; // Should never reach here after initialize(), but safe default. + + if (legacyEnabled && pmEnabled) { try { registry.registerTool(new ToolClass(...args)); } catch (error) { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 3cdc8232f..eb1567170 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -746,27 +746,43 @@ export class CoreToolScheduler { (reqInfo): ToolCall => { // Check if the tool is excluded due to permissions/environment restrictions // This check should happen before registry lookup to provide a clear permission error - const excludeTools = this.config.getExcludeTools?.() ?? undefined; - if (excludeTools && excludeTools.length > 0) { - const normalizedToolName = reqInfo.name.toLowerCase().trim(); - const excludedMatch = excludeTools.find( - (excludedTool) => - excludedTool.toLowerCase().trim() === normalizedToolName, - ); + const pm = this.config.getPermissionManager?.(); + if (pm && !pm.isToolEnabled(reqInfo.name)) { + const permissionErrorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`; + return { + status: 'error', + request: reqInfo, + response: createErrorResponse( + reqInfo, + new Error(permissionErrorMessage), + ToolErrorType.EXECUTION_DENIED, + ), + durationMs: 0, + }; + } - if (excludedMatch) { - // The tool exists but is excluded - return permission error directly - const permissionErrorMessage = `Qwen Code requires permission to use ${excludedMatch}, but that permission was declined.`; - return { - status: 'error', - request: reqInfo, - response: createErrorResponse( - reqInfo, - new Error(permissionErrorMessage), - ToolErrorType.EXECUTION_DENIED, - ), - durationMs: 0, - }; + // Legacy fallback: check getExcludeTools() when PM is not available + if (!pm) { + const excludeTools = this.config.getExcludeTools?.() ?? undefined; + if (excludeTools && excludeTools.length > 0) { + const normalizedToolName = reqInfo.name.toLowerCase().trim(); + const excludedMatch = excludeTools.find( + (excludedTool) => + excludedTool.toLowerCase().trim() === normalizedToolName, + ); + if (excludedMatch) { + const permissionErrorMessage = `Qwen Code requires permission to use ${excludedMatch}, but that permission was declined.`; + return { + status: 'error', + request: reqInfo, + response: createErrorResponse( + reqInfo, + new Error(permissionErrorMessage), + ToolErrorType.EXECUTION_DENIED, + ), + durationMs: 0, + }; + } } } @@ -868,7 +884,51 @@ export class CoreToolScheduler { continue; } - const allowedTools = this.config.getAllowedTools() || []; + // Determine if this invocation is auto-approved via PermissionManager + const pm = this.config.getPermissionManager?.(); + const isAutoApproved = (() => { + if (this.config.getApprovalMode() === ApprovalMode.YOLO) + return true; + if (pm) { + // Build invocation context from tool params. + // Different tool types contribute different context fields: + // - Shell tools: command + // - File read/edit/write tools: filePath (via absolute_path or file_path) + // - WebFetch: domain (extracted from url param) + const params = invocation.params as Record; + const shellCommand = + 'command' in params ? String(params['command']) : undefined; + const filePath = + typeof params['absolute_path'] === 'string' + ? params['absolute_path'] + : typeof params['file_path'] === 'string' + ? params['file_path'] + : undefined; + let domain: string | undefined; + if (typeof params['url'] === 'string') { + try { + domain = new URL(params['url']).hostname; + } catch { + // malformed URL — leave domain undefined + } + } + const decision = pm.evaluate({ + toolName: reqInfo.name, + command: shellCommand, + filePath, + domain, + }); + return decision === 'allow'; + } + // Legacy fallback: check getAllowedTools() when PM is not available + const allowedTools = this.config.getAllowedTools() || []; + return doesToolInvocationMatch( + toolCall.tool, + invocation, + allowedTools, + ); + })(); + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; const isExitPlanModeTool = reqInfo.name === 'exit_plan_mode'; @@ -889,10 +949,7 @@ export class CoreToolScheduler { } else { this.setStatusInternal(reqInfo.callId, 'scheduled'); } - } else if ( - this.config.getApprovalMode() === ApprovalMode.YOLO || - doesToolInvocationMatch(toolCall.tool, invocation, allowedTools) - ) { + } else if (isAutoApproved) { this.setToolCallOutcome( reqInfo.callId, ToolConfirmationOutcome.ProceedAlways, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2800e20f6..c17ba27b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,9 @@ export * from './config/config.js'; export { Storage } from './config/storage.js'; export * from './utils/configResolver.js'; +// Permission system +export * from './permissions/index.js'; + // Model configuration export { DEFAULT_QWEN_MODEL, diff --git a/packages/core/src/permissions/index.ts b/packages/core/src/permissions/index.ts new file mode 100644 index 000000000..0e3b44f90 --- /dev/null +++ b/packages/core/src/permissions/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './types.js'; +export * from './rule-parser.js'; +export { PermissionManager } from './permission-manager.js'; +export type { PermissionManagerConfig } from './permission-manager.js'; diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts new file mode 100644 index 000000000..9767da7d1 --- /dev/null +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -0,0 +1,967 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + parseRule, + parseRules, + matchesRule, + matchesCommandPattern, + matchesPathPattern, + matchesDomainPattern, + resolveToolName, + resolvePathPattern, + getSpecifierKind, + toolMatchesRuleToolName, +} from './rule-parser.js'; +import { PermissionManager } from './permission-manager.js'; +import type { PermissionManagerConfig } from './permission-manager.js'; + +// ─── resolveToolName ───────────────────────────────────────────────────────── + +describe('resolveToolName', () => { + it('resolves canonical names', () => { + expect(resolveToolName('run_shell_command')).toBe('run_shell_command'); + expect(resolveToolName('read_file')).toBe('read_file'); + }); + + it('resolves display-name aliases', () => { + expect(resolveToolName('Shell')).toBe('run_shell_command'); + expect(resolveToolName('ShellTool')).toBe('run_shell_command'); + expect(resolveToolName('Bash')).toBe('run_shell_command'); + expect(resolveToolName('ReadFile')).toBe('read_file'); + expect(resolveToolName('ReadFileTool')).toBe('read_file'); + expect(resolveToolName('EditTool')).toBe('edit'); + expect(resolveToolName('WriteFileTool')).toBe('write_file'); + }); + + it('resolves "Read" and "Edit" meta-categories', () => { + expect(resolveToolName('Read')).toBe('read_file'); + expect(resolveToolName('Edit')).toBe('edit'); + expect(resolveToolName('Write')).toBe('write_file'); + }); + + it('resolves Agent category', () => { + expect(resolveToolName('Agent')).toBe('Agent'); + }); + + it('returns unknown names unchanged', () => { + expect(resolveToolName('my_mcp_tool')).toBe('my_mcp_tool'); + expect(resolveToolName('mcp__server__tool')).toBe('mcp__server__tool'); + }); +}); + +// ─── getSpecifierKind ──────────────────────────────────────────────────────── + +describe('getSpecifierKind', () => { + it('returns "command" for shell tools', () => { + expect(getSpecifierKind('run_shell_command')).toBe('command'); + }); + + it('returns "path" for file read/edit tools', () => { + expect(getSpecifierKind('read_file')).toBe('path'); + expect(getSpecifierKind('edit')).toBe('path'); + expect(getSpecifierKind('write_file')).toBe('path'); + expect(getSpecifierKind('grep_search')).toBe('path'); + expect(getSpecifierKind('glob')).toBe('path'); + expect(getSpecifierKind('list_directory')).toBe('path'); + }); + + it('returns "domain" for web fetch tools', () => { + expect(getSpecifierKind('web_fetch')).toBe('domain'); + }); + + it('returns "literal" for other tools', () => { + expect(getSpecifierKind('Agent')).toBe('literal'); + expect(getSpecifierKind('task')).toBe('literal'); + expect(getSpecifierKind('mcp__server')).toBe('literal'); + }); +}); + +// ─── toolMatchesRuleToolName ───────────────────────────────────────────────── + +describe('toolMatchesRuleToolName', () => { + it('exact match', () => { + expect(toolMatchesRuleToolName('read_file', 'read_file')).toBe(true); + expect(toolMatchesRuleToolName('edit', 'edit')).toBe(true); + }); + + it('"Read" (read_file) covers grep_search, glob, list_directory', () => { + expect(toolMatchesRuleToolName('read_file', 'grep_search')).toBe(true); + expect(toolMatchesRuleToolName('read_file', 'glob')).toBe(true); + expect(toolMatchesRuleToolName('read_file', 'list_directory')).toBe(true); + }); + + it('"Edit" (edit) covers write_file', () => { + expect(toolMatchesRuleToolName('edit', 'write_file')).toBe(true); + }); + + it('does not cross categories', () => { + expect(toolMatchesRuleToolName('read_file', 'edit')).toBe(false); + expect(toolMatchesRuleToolName('edit', 'read_file')).toBe(false); + expect(toolMatchesRuleToolName('read_file', 'run_shell_command')).toBe( + false, + ); + }); +}); + +// ─── parseRule ─────────────────────────────────────────────────────────────── + +describe('parseRule', () => { + it('parses a simple tool name', () => { + const r = parseRule('ShellTool'); + expect(r.raw).toBe('ShellTool'); + expect(r.toolName).toBe('run_shell_command'); + expect(r.specifier).toBeUndefined(); + expect(r.specifierKind).toBeUndefined(); + }); + + it('parses Bash alias (Claude Code compat)', () => { + const r = parseRule('Bash'); + expect(r.toolName).toBe('run_shell_command'); + }); + + it('parses a shell tool with a specifier', () => { + const r = parseRule('Bash(git *)'); + expect(r.toolName).toBe('run_shell_command'); + expect(r.specifier).toBe('git *'); + expect(r.specifierKind).toBe('command'); + }); + + it('parses Read with path specifier', () => { + const r = parseRule('Read(./secrets/**)'); + expect(r.toolName).toBe('read_file'); + expect(r.specifier).toBe('./secrets/**'); + expect(r.specifierKind).toBe('path'); + }); + + it('parses Edit with path specifier', () => { + const r = parseRule('Edit(/src/**/*.ts)'); + expect(r.toolName).toBe('edit'); + expect(r.specifier).toBe('/src/**/*.ts'); + expect(r.specifierKind).toBe('path'); + }); + + it('parses WebFetch with domain specifier', () => { + const r = parseRule('WebFetch(domain:example.com)'); + expect(r.toolName).toBe('web_fetch'); + expect(r.specifier).toBe('domain:example.com'); + expect(r.specifierKind).toBe('domain'); + }); + + it('parses Agent with literal specifier', () => { + const r = parseRule('Agent(Explore)'); + expect(r.toolName).toBe('Agent'); + expect(r.specifier).toBe('Explore'); + expect(r.specifierKind).toBe('literal'); + }); + + it('handles unknown tools without specifier', () => { + const r = parseRule('mcp__my_server__my_tool'); + expect(r.toolName).toBe('mcp__my_server__my_tool'); + expect(r.specifier).toBeUndefined(); + }); + + it('handles legacy :* suffix (deprecated)', () => { + const r = parseRule('Bash(git:*)'); + expect(r.toolName).toBe('run_shell_command'); + expect(r.specifier).toBe('git *'); + }); + + it('handles malformed pattern (no closing paren)', () => { + const r = parseRule('Bash(git status'); + expect(r.specifier).toBeUndefined(); + }); +}); + +// ─── parseRules ────────────────────────────────────────────────────────────── + +describe('parseRules', () => { + it('filters empty strings', () => { + const rules = parseRules(['ShellTool', '', ' ', 'ReadFileTool']); + expect(rules).toHaveLength(2); + }); +}); + +// ─── matchesCommandPattern (Shell glob) ────────────────────────────────────── + +describe('matchesCommandPattern', () => { + // Basic prefix matching (no wildcards) + describe('prefix matching without glob', () => { + it('exact match', () => { + expect(matchesCommandPattern('git', 'git')).toBe(true); + }); + + it('prefix + space', () => { + expect(matchesCommandPattern('git', 'git status')).toBe(true); + expect(matchesCommandPattern('git commit', 'git commit -m "test"')).toBe( + true, + ); + }); + + it('does not match as substring', () => { + expect(matchesCommandPattern('git', 'gitcommit')).toBe(false); + }); + }); + + // Wildcard at tail + describe('wildcard at tail', () => { + it('matches any arguments', () => { + expect(matchesCommandPattern('git *', 'git status')).toBe(true); + expect(matchesCommandPattern('git *', 'git commit -m "test"')).toBe(true); + expect(matchesCommandPattern('npm run *', 'npm run build')).toBe(true); + }); + + it('does not match different command', () => { + expect(matchesCommandPattern('git *', 'echo hello')).toBe(false); + }); + }); + + // Wildcard at head + describe('wildcard at head', () => { + it('matches any command ending with pattern', () => { + expect(matchesCommandPattern('* --version', 'node --version')).toBe(true); + expect(matchesCommandPattern('* --version', 'npm --version')).toBe(true); + expect(matchesCommandPattern('* --help *', 'npm --help install')).toBe( + true, + ); + }); + + it('does not match non-matching suffix', () => { + expect(matchesCommandPattern('* --version', 'node --help')).toBe(false); + }); + }); + + // Wildcard in middle + describe('wildcard in middle', () => { + it('matches middle segments', () => { + expect(matchesCommandPattern('git * main', 'git checkout main')).toBe( + true, + ); + expect(matchesCommandPattern('git * main', 'git merge main')).toBe(true); + }); + + it('does not match different suffix', () => { + expect(matchesCommandPattern('git * main', 'git checkout dev')).toBe( + false, + ); + }); + }); + + // Word boundary rule: space before * matters + describe('word boundary rule (space before *)', () => { + it('Bash(ls *): matches "ls -la" but NOT "lsof"', () => { + expect(matchesCommandPattern('ls *', 'ls -la')).toBe(true); + expect(matchesCommandPattern('ls *', 'ls')).toBe(true); // "ls" alone + expect(matchesCommandPattern('ls *', 'lsof')).toBe(false); + }); + + it('Bash(ls*): matches both "ls -la" and "lsof"', () => { + expect(matchesCommandPattern('ls*', 'ls -la')).toBe(true); + expect(matchesCommandPattern('ls*', 'lsof')).toBe(true); + expect(matchesCommandPattern('ls*', 'ls')).toBe(true); + }); + + it('Bash(npm *): matches "npm run" but NOT "npmx"', () => { + expect(matchesCommandPattern('npm *', 'npm run build')).toBe(true); + expect(matchesCommandPattern('npm *', 'npmx install')).toBe(false); + }); + }); + + // Shell operator awareness + // + // Key insight: operator boundary extraction means we only match against + // the FIRST simple command. So `git *` still matches `git status && rm -rf /` + // because the first command IS `git status` which matches `git *`. + // + // The safety benefit: a pattern like `rm *` would NOT match + // `git status && rm -rf /` because the first command is `git status`. + describe('shell operator boundaries', () => { + it('first-command extraction: git * matches first cmd in compound', () => { + // First command is "git status", which matches "git *" + expect(matchesCommandPattern('git *', 'git status && rm -rf /')).toBe( + true, + ); + }); + + it('second command is not reachable: rm * does not match compound starting with git', () => { + // First command is "git status", NOT "rm -rf /" + expect(matchesCommandPattern('rm *', 'git status && rm -rf /')).toBe( + false, + ); + }); + + it('pipe boundary: grep * does not match first command', () => { + // First command is "git status", not "grep foo" + expect(matchesCommandPattern('grep *', 'git status | grep foo')).toBe( + false, + ); + }); + + it('semicolon boundary: rm * does not match first command', () => { + // First command is "git status", not "rm -rf /" + expect(matchesCommandPattern('rm *', 'git status; rm -rf /')).toBe(false); + }); + + it('|| boundary: echo * does not match first command', () => { + expect(matchesCommandPattern('echo *', 'git status || echo fail')).toBe( + false, + ); + }); + + it('matches when no operators are present', () => { + expect( + matchesCommandPattern('git *', 'git commit -m "hello world"'), + ).toBe(true); + }); + + it('operators inside quotes are not boundaries', () => { + // "echo 'a && b'" → first command is the whole thing because && is inside quotes + expect(matchesCommandPattern('echo *', "echo 'a && b'")).toBe(true); + }); + }); + + // Special: lone * matches any command + describe('lone wildcard', () => { + it('* matches any single command', () => { + expect(matchesCommandPattern('*', 'anything here')).toBe(true); + }); + }); + + // Exact command match with specifier + describe('exact command specifier', () => { + it('Bash(npm run build) matches exact command', () => { + expect(matchesCommandPattern('npm run build', 'npm run build')).toBe( + true, + ); + }); + it('Bash(npm run build) also matches with trailing args (prefix)', () => { + expect( + matchesCommandPattern('npm run build', 'npm run build --verbose'), + ).toBe(true); + }); + it('Bash(npm run build) does not match different command', () => { + expect(matchesCommandPattern('npm run build', 'npm run test')).toBe( + false, + ); + }); + }); +}); + +// ─── resolvePathPattern ────────────────────────────────────────────────────── + +describe('resolvePathPattern', () => { + const projectRoot = '/project'; + const cwd = '/project/subdir'; + + it('// prefix → absolute from filesystem root', () => { + expect( + resolvePathPattern('//Users/alice/secrets/**', projectRoot, cwd), + ).toBe('/Users/alice/secrets/**'); + }); + + it('~/ prefix → relative to home directory', () => { + const result = resolvePathPattern('~/Documents/*.pdf', projectRoot, cwd); + expect(result).toContain('Documents/*.pdf'); + // Should start with actual home directory + expect(result.startsWith('/')).toBe(true); + }); + + it('/ prefix → relative to project root (NOT absolute)', () => { + expect(resolvePathPattern('/src/**/*.ts', projectRoot, cwd)).toBe( + '/project/src/**/*.ts', + ); + }); + + it('./ prefix → relative to cwd', () => { + expect(resolvePathPattern('./secrets/**', projectRoot, cwd)).toBe( + '/project/subdir/secrets/**', + ); + }); + + it('no prefix → relative to cwd', () => { + expect(resolvePathPattern('*.env', projectRoot, cwd)).toBe( + '/project/subdir/*.env', + ); + }); + + it('/Users/alice/file is relative to project root, NOT absolute', () => { + // This is a gotcha from the Claude Code docs + expect(resolvePathPattern('/Users/alice/file', projectRoot, cwd)).toBe( + '/project/Users/alice/file', + ); + }); +}); + +// ─── matchesPathPattern ────────────────────────────────────────────────────── + +describe('matchesPathPattern', () => { + const projectRoot = '/project'; + const cwd = '/project'; + + it('matches dotfiles (e.g. .env)', () => { + expect(matchesPathPattern('.env', '/project/.env', projectRoot, cwd)).toBe( + true, + ); + expect(matchesPathPattern('*.env', '/project/.env', projectRoot, cwd)).toBe( + true, + ); + }); + + it('** matches recursively across directories', () => { + expect( + matchesPathPattern( + './secrets/**', + '/project/secrets/deep/nested/file.txt', + projectRoot, + cwd, + ), + ).toBe(true); + }); + + it('* matches single directory only', () => { + expect( + matchesPathPattern( + '/src/*.ts', + '/project/src/index.ts', + projectRoot, + cwd, + ), + ).toBe(true); + expect( + matchesPathPattern( + '/src/*.ts', + '/project/src/nested/index.ts', + projectRoot, + cwd, + ), + ).toBe(false); + }); + + it('/docs/** matches under project root docs', () => { + expect( + matchesPathPattern( + '/docs/**', + '/project/docs/readme.md', + projectRoot, + cwd, + ), + ).toBe(true); + expect( + matchesPathPattern( + '/docs/**', + '/project/src/docs/readme.md', + projectRoot, + cwd, + ), + ).toBe(false); + }); + + it('//tmp/scratch.txt matches absolute path', () => { + expect( + matchesPathPattern( + '//tmp/scratch.txt', + '/tmp/scratch.txt', + projectRoot, + cwd, + ), + ).toBe(true); + }); + + it('does not match unrelated paths', () => { + expect( + matchesPathPattern( + './secrets/**', + '/project/public/index.html', + projectRoot, + cwd, + ), + ).toBe(false); + }); +}); + +// ─── matchesDomainPattern ──────────────────────────────────────────────────── + +describe('matchesDomainPattern', () => { + it('matches exact domain', () => { + expect(matchesDomainPattern('domain:example.com', 'example.com')).toBe( + true, + ); + }); + + it('matches subdomain', () => { + expect(matchesDomainPattern('domain:example.com', 'sub.example.com')).toBe( + true, + ); + expect( + matchesDomainPattern('domain:example.com', 'deep.sub.example.com'), + ).toBe(true); + }); + + it('does not match different domain', () => { + expect(matchesDomainPattern('domain:example.com', 'notexample.com')).toBe( + false, + ); + }); + + it('is case-insensitive', () => { + expect(matchesDomainPattern('domain:Example.COM', 'example.com')).toBe( + true, + ); + }); + + it('handles missing prefix', () => { + expect(matchesDomainPattern('example.com', 'example.com')).toBe(true); + }); +}); + +// ─── matchesRule (unified) ─────────────────────────────────────────────────── + +describe('matchesRule', () => { + // Basic tool name matching + it('simple tool-name rule matches any invocation', () => { + const rule = parseRule('ShellTool'); + expect(matchesRule(rule, 'run_shell_command')).toBe(true); + expect(matchesRule(rule, 'run_shell_command', 'git status')).toBe(true); + }); + + it('does not match a different tool', () => { + const rule = parseRule('ShellTool'); + expect(matchesRule(rule, 'read_file')).toBe(false); + }); + + // Shell command specifier + it('specifier rule requires a command for shell tools', () => { + const rule = parseRule('Bash(git *)'); + expect(matchesRule(rule, 'run_shell_command')).toBe(false); // no command + expect(matchesRule(rule, 'run_shell_command', 'git status')).toBe(true); + expect(matchesRule(rule, 'run_shell_command', 'echo hello')).toBe(false); + }); + + it('operator boundary: pattern matches first command only', () => { + const rule = parseRule('Bash(git *)'); + // First command is "git status" which matches "git *" → true + expect( + matchesRule(rule, 'run_shell_command', 'git status && rm -rf /'), + ).toBe(true); + // rm * would not match because first command is "git status" + const rmRule = parseRule('Bash(rm *)'); + expect( + matchesRule(rmRule, 'run_shell_command', 'git status && rm -rf /'), + ).toBe(false); + }); + + // Meta-category matching: Read + it('Read rule matches grep_search, glob, list_directory', () => { + const rule = parseRule('Read'); + expect(matchesRule(rule, 'read_file')).toBe(true); + expect(matchesRule(rule, 'grep_search')).toBe(true); + expect(matchesRule(rule, 'glob')).toBe(true); + expect(matchesRule(rule, 'list_directory')).toBe(true); + expect(matchesRule(rule, 'edit')).toBe(false); // not a read tool + }); + + // Meta-category matching: Edit + it('Edit rule matches edit and write_file', () => { + const rule = parseRule('Edit'); + expect(matchesRule(rule, 'edit')).toBe(true); + expect(matchesRule(rule, 'write_file')).toBe(true); + expect(matchesRule(rule, 'read_file')).toBe(false); // not an edit tool + }); + + // File path matching + it('Read with path specifier requires filePath', () => { + const rule = parseRule('Read(.env)'); + const pathCtx = { projectRoot: '/project', cwd: '/project' }; + // No filePath → no match + expect(matchesRule(rule, 'read_file')).toBe(false); + // With filePath + expect( + matchesRule( + rule, + 'read_file', + undefined, + '/project/.env', + undefined, + pathCtx, + ), + ).toBe(true); + expect( + matchesRule( + rule, + 'read_file', + undefined, + '/project/other.txt', + undefined, + pathCtx, + ), + ).toBe(false); + }); + + it('Edit path specifier matches write_file too', () => { + const rule = parseRule('Edit(/src/**/*.ts)'); + const pathCtx = { projectRoot: '/project', cwd: '/project' }; + expect( + matchesRule( + rule, + 'write_file', + undefined, + '/project/src/index.ts', + undefined, + pathCtx, + ), + ).toBe(true); + expect( + matchesRule( + rule, + 'write_file', + undefined, + '/project/docs/readme.md', + undefined, + pathCtx, + ), + ).toBe(false); + }); + + // WebFetch domain matching + it('WebFetch domain specifier', () => { + const rule = parseRule('WebFetch(domain:example.com)'); + expect( + matchesRule(rule, 'web_fetch', undefined, undefined, 'example.com'), + ).toBe(true); + expect( + matchesRule(rule, 'web_fetch', undefined, undefined, 'sub.example.com'), + ).toBe(true); + expect( + matchesRule(rule, 'web_fetch', undefined, undefined, 'other.com'), + ).toBe(false); + // No domain → no match + expect(matchesRule(rule, 'web_fetch')).toBe(false); + }); + + // Agent literal matching + it('Agent literal specifier', () => { + const rule = parseRule('Agent(Explore)'); + // Agent rules use `command` field for the agent name + expect(matchesRule(rule, 'Agent', 'Explore')).toBe(true); + expect(matchesRule(rule, 'Agent', 'Plan')).toBe(false); + expect(matchesRule(rule, 'Agent')).toBe(false); // no agent name + }); + + // MCP tool matching + it('MCP tool exact match', () => { + const rule = parseRule('mcp__puppeteer__puppeteer_navigate'); + expect(matchesRule(rule, 'mcp__puppeteer__puppeteer_navigate')).toBe(true); + expect(matchesRule(rule, 'mcp__puppeteer__puppeteer_click')).toBe(false); + }); + + it('MCP server-level match (2-part pattern)', () => { + const rule = parseRule('mcp__puppeteer'); + expect(matchesRule(rule, 'mcp__puppeteer__puppeteer_navigate')).toBe(true); + expect(matchesRule(rule, 'mcp__puppeteer__puppeteer_click')).toBe(true); + expect(matchesRule(rule, 'mcp__other__tool')).toBe(false); + }); + + it('MCP wildcard match', () => { + const rule = parseRule('mcp__puppeteer__*'); + expect(matchesRule(rule, 'mcp__puppeteer__puppeteer_navigate')).toBe(true); + expect(matchesRule(rule, 'mcp__other__tool')).toBe(false); + }); +}); + +// ─── PermissionManager ────────────────────────────────────────────────────── + +function makeConfig( + opts: Partial<{ + permissionsAllow: string[]; + permissionsAsk: string[]; + permissionsDeny: string[]; + projectRoot: string; + cwd: string; + }> = {}, +): PermissionManagerConfig { + return { + getPermissionsAllow: () => opts.permissionsAllow, + getPermissionsAsk: () => opts.permissionsAsk, + getPermissionsDeny: () => opts.permissionsDeny, + getProjectRoot: () => opts.projectRoot ?? '/project', + getCwd: () => opts.cwd ?? '/project', + }; +} + +describe('PermissionManager', () => { + let pm: PermissionManager; + + describe('basic rule evaluation', () => { + beforeEach(() => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['ReadFileTool', 'Bash(git *)'], + permissionsAsk: ['WriteFileTool'], + permissionsDeny: ['ShellTool'], + }), + ); + pm.initialize(); + }); + + it('returns deny for a denied tool', () => { + expect(pm.evaluate({ toolName: 'run_shell_command' })).toBe('deny'); + }); + + it('returns ask for an ask-rule tool', () => { + expect(pm.evaluate({ toolName: 'write_file' })).toBe('ask'); + }); + + it('returns allow for an allow-rule tool', () => { + expect(pm.evaluate({ toolName: 'read_file' })).toBe('allow'); + }); + + it('returns default for unmatched tool', () => { + // Note: 'glob' is covered by ReadFileTool via Read meta-category, + // so use a tool not in any rule or meta-category + expect(pm.evaluate({ toolName: 'task' })).toBe('default'); + }); + + it('deny takes precedence over ask and allow', () => { + const pm2 = new PermissionManager( + makeConfig({ + permissionsAllow: ['run_shell_command'], + permissionsAsk: ['run_shell_command'], + permissionsDeny: ['run_shell_command'], + }), + ); + pm2.initialize(); + expect(pm2.evaluate({ toolName: 'run_shell_command' })).toBe('deny'); + }); + + it('ask takes precedence over allow', () => { + const pm2 = new PermissionManager( + makeConfig({ + permissionsAllow: ['write_file'], + permissionsAsk: ['write_file'], + }), + ); + pm2.initialize(); + expect(pm2.evaluate({ toolName: 'write_file' })).toBe('ask'); + }); + }); + + describe('command-level evaluation', () => { + beforeEach(() => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['Bash(git *)'], + permissionsDeny: ['Bash(rm *)'], + }), + ); + pm.initialize(); + }); + + it('allows a matching allowed command', () => { + expect( + pm.evaluate({ toolName: 'run_shell_command', command: 'git status' }), + ).toBe('allow'); + }); + + it('denies a matching denied command', () => { + expect( + pm.evaluate({ toolName: 'run_shell_command', command: 'rm -rf /' }), + ).toBe('deny'); + }); + + it('returns default for an unmatched command', () => { + expect( + pm.evaluate({ toolName: 'run_shell_command', command: 'echo hello' }), + ).toBe('default'); + }); + + it('isCommandAllowed delegates to evaluate', () => { + expect(pm.isCommandAllowed('git commit')).toBe('allow'); + expect(pm.isCommandAllowed('rm -rf /')).toBe('deny'); + expect(pm.isCommandAllowed('ls')).toBe('default'); + }); + }); + + describe('file path evaluation', () => { + beforeEach(() => { + pm = new PermissionManager( + makeConfig({ + permissionsDeny: ['Read(.env)', 'Edit(/src/generated/**)'], + permissionsAllow: ['Read(/docs/**)'], + projectRoot: '/project', + cwd: '/project', + }), + ); + pm.initialize(); + }); + + it('denies reading a denied file', () => { + expect( + pm.evaluate({ toolName: 'read_file', filePath: '/project/.env' }), + ).toBe('deny'); + }); + + it('denies editing in a denied directory', () => { + expect( + pm.evaluate({ + toolName: 'edit', + filePath: '/project/src/generated/code.ts', + }), + ).toBe('deny'); + }); + + it('allows reading in an allowed directory', () => { + expect( + pm.evaluate({ + toolName: 'read_file', + filePath: '/project/docs/readme.md', + }), + ).toBe('allow'); + }); + + it('Read deny applies to grep_search too (meta-category)', () => { + expect( + pm.evaluate({ toolName: 'grep_search', filePath: '/project/.env' }), + ).toBe('deny'); + }); + + it('returns default for unmatched path', () => { + expect( + pm.evaluate({ + toolName: 'read_file', + filePath: '/project/src/index.ts', + }), + ).toBe('default'); + }); + }); + + describe('WebFetch domain evaluation', () => { + beforeEach(() => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['WebFetch(domain:github.com)'], + permissionsDeny: ['WebFetch(domain:evil.com)'], + }), + ); + pm.initialize(); + }); + + it('allows fetch to allowed domain', () => { + expect(pm.evaluate({ toolName: 'web_fetch', domain: 'github.com' })).toBe( + 'allow', + ); + }); + + it('allows fetch to subdomain of allowed domain', () => { + expect( + pm.evaluate({ toolName: 'web_fetch', domain: 'api.github.com' }), + ).toBe('allow'); + }); + + it('denies fetch to denied domain', () => { + expect(pm.evaluate({ toolName: 'web_fetch', domain: 'evil.com' })).toBe( + 'deny', + ); + }); + + it('returns default for unmatched domain', () => { + expect( + pm.evaluate({ toolName: 'web_fetch', domain: 'example.com' }), + ).toBe('default'); + }); + }); + + describe('isToolEnabled', () => { + it('returns false for deny-ruled tools', () => { + pm = new PermissionManager( + makeConfig({ permissionsDeny: ['ShellTool'] }), + ); + pm.initialize(); + expect(pm.isToolEnabled('run_shell_command')).toBe(false); + }); + + it('returns true for tools with only specifier deny rules', () => { + pm = new PermissionManager( + makeConfig({ permissionsDeny: ['Bash(rm *)'] }), + ); + pm.initialize(); + expect(pm.isToolEnabled('run_shell_command')).toBe(true); + }); + + it('excludeTools passed via permissionsDeny disables the tool', () => { + pm = new PermissionManager( + makeConfig({ permissionsDeny: ['run_shell_command'] }), + ); + pm.initialize(); + expect(pm.isToolEnabled('run_shell_command')).toBe(false); + }); + + it('coreTools allowlist passed via permissionsAllow enables only listed tools', () => { + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['read_file'] }), + ); + pm.initialize(); + expect(pm.isToolEnabled('read_file')).toBe(true); + expect(pm.isToolEnabled('run_shell_command')).toBe(true); + }); + }); + + describe('session rules', () => { + beforeEach(() => { + pm = new PermissionManager(makeConfig({})); + pm.initialize(); + }); + + it('addSessionAllowRule enables auto-approval for that pattern', () => { + expect( + pm.evaluate({ toolName: 'run_shell_command', command: 'git status' }), + ).toBe('default'); + pm.addSessionAllowRule('Bash(git *)'); + expect( + pm.evaluate({ toolName: 'run_shell_command', command: 'git status' }), + ).toBe('allow'); + }); + + it('session deny rules override allow rules', () => { + pm.addSessionAllowRule('run_shell_command'); + pm.addSessionDenyRule('run_shell_command'); + expect(pm.evaluate({ toolName: 'run_shell_command' })).toBe('deny'); + }); + }); + + describe('allowedTools via permissionsAllow', () => { + it('allow rule auto-approves matching tools/commands', () => { + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['ReadFileTool', 'Bash(git *)'] }), + ); + pm.initialize(); + expect(pm.evaluate({ toolName: 'read_file' })).toBe('allow'); + expect( + pm.evaluate({ toolName: 'run_shell_command', command: 'git status' }), + ).toBe('allow'); + }); + }); + + describe('listRules', () => { + it('returns all rules with type and scope', () => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['ReadFileTool'], + permissionsDeny: ['ShellTool'], + }), + ); + pm.initialize(); + pm.addSessionAllowRule('Bash(git *)'); + + const rules = pm.listRules(); + expect(rules.length).toBe(3); + const sessionAllow = rules.find( + (r) => r.scope === 'session' && r.type === 'allow', + ); + expect(sessionAllow?.rule.toolName).toBe('run_shell_command'); + }); + }); +}); diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts new file mode 100644 index 000000000..4980dd288 --- /dev/null +++ b/packages/core/src/permissions/permission-manager.ts @@ -0,0 +1,333 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + parseRules, + parseRule, + matchesRule, + resolveToolName, +} from './rule-parser.js'; +import type { PathMatchContext } from './rule-parser.js'; +import type { + PermissionCheckContext, + PermissionDecision, + PermissionRule, + PermissionRuleSet, + RuleType, + RuleWithSource, + RuleScope, +} from './types.js'; + +/** + * Minimal interface for the parts of Config used by PermissionManager. + * Keeps the dependency explicit and avoids a circular import on the + * full Config class. + * + * Each getter already returns a fully-merged list: persistent settings rules + * plus any SDK / CLI params that have been folded in by the Config layer. + * PermissionManager therefore only needs these three getters. + */ +export interface PermissionManagerConfig { + /** Merged allow-rules (settings + coreTools + allowedTools). */ + getPermissionsAllow(): string[] | undefined; + /** Merged ask-rules (settings only). */ + getPermissionsAsk(): string[] | undefined; + /** Merged deny-rules (settings + excludeTools). */ + getPermissionsDeny(): string[] | undefined; + /** Project root directory (for resolving path patterns). */ + getProjectRoot?(): string; + /** Current working directory (for resolving path patterns). */ + getCwd?(): string; + /** + * Returns the current approval mode (plan/default/auto-edit/yolo). + * Used by `getDefaultMode()` to determine the fallback when no rule matches. + */ + getApprovalMode?(): string; +} + +/** + * Manages tool and command permissions by evaluating a set of + * prioritised rules against allow / ask / deny lists. + * + * Rule evaluation order (highest priority first): + * 1. deny rules → PermissionDecision.deny + * 2. ask rules → PermissionDecision.ask + * 3. allow rules → PermissionDecision.allow + * 4. (no match) → PermissionDecision.default + * + * Rules can come from three sources, checked in order within each type: + * - Session rules (in-memory only, added during the current session) + * - Persistent rules (from settings files, passed via ConfigParameters) + * + * Legacy params (coreTools / allowedTools / excludeTools) are converted + * to in-memory rules for backward compatibility with the SDK API. + */ +export class PermissionManager { + /** Persistent rules loaded from settings (all scopes merged). */ + private persistentRules: PermissionRuleSet = { + allow: [], + ask: [], + deny: [], + }; + + /** In-memory rules added for the current session only. */ + private sessionRules: PermissionRuleSet = { + allow: [], + ask: [], + deny: [], + }; + + constructor(private readonly config: PermissionManagerConfig) {} + + /** + * Initialise from the config's permission parameters. + * Must be called once before any rule lookups. + * + * The config getters already return fully-merged lists (settings + SDK params), + * so we simply parse them into typed rules. + */ + initialize(): void { + this.persistentRules = { + allow: parseRules(this.config.getPermissionsAllow() ?? []), + ask: parseRules(this.config.getPermissionsAsk() ?? []), + deny: parseRules(this.config.getPermissionsDeny() ?? []), + }; + } + + // --------------------------------------------------------------------------- + // Core evaluation + // --------------------------------------------------------------------------- + + /** + * Evaluate the permission decision for a given tool invocation context. + * + * @param ctx - The context containing the tool name and optional command. + * @returns A PermissionDecision indicating how to handle this tool call. + */ + evaluate(ctx: PermissionCheckContext): PermissionDecision { + const { toolName, command, filePath, domain } = ctx; + + // Build path context for resolving relative path patterns + const pathCtx: PathMatchContext | undefined = + this.config.getProjectRoot && this.config.getCwd + ? { + projectRoot: this.config.getProjectRoot(), + cwd: this.config.getCwd(), + } + : undefined; + + const matchArgs = [toolName, command, filePath, domain, pathCtx] as const; + + // Priority 1: deny rules (session first, then persistent) + for (const rule of [ + ...this.sessionRules.deny, + ...this.persistentRules.deny, + ]) { + if (matchesRule(rule, ...matchArgs)) { + return 'deny'; + } + } + + // Priority 2: ask rules + for (const rule of [ + ...this.sessionRules.ask, + ...this.persistentRules.ask, + ]) { + if (matchesRule(rule, ...matchArgs)) { + return 'ask'; + } + } + + // Priority 3: allow rules + for (const rule of [ + ...this.sessionRules.allow, + ...this.persistentRules.allow, + ]) { + if (matchesRule(rule, ...matchArgs)) { + return 'allow'; + } + } + + return 'default'; + } + + // --------------------------------------------------------------------------- + // Registry-level helper + // --------------------------------------------------------------------------- + + /** + * Determine whether a tool should be present in the tool registry. + * + * A tool is disabled (returns false) when a `deny` rule without a specifier + * (i.e. a whole-tool deny) matches. Specifier-based deny rules such as + * `"Bash(rm -rf *)"` do NOT remove the tool from the registry – they only + * deny specific invocations at runtime. + */ + isToolEnabled(toolName: string): boolean { + const canonicalName = resolveToolName(toolName); + // evaluate({ toolName }) without a command will only match rules that have + // no specifier, which is the correct registry-level check. + const decision = this.evaluate({ toolName: canonicalName }); + return decision !== 'deny'; + } + + // --------------------------------------------------------------------------- + // Shell command helper + // --------------------------------------------------------------------------- + + /** + * Determine the permission decision for a specific shell command string. + * + * @param command - The shell command to evaluate. + * @returns The PermissionDecision for this command. + */ + isCommandAllowed(command: string): PermissionDecision { + return this.evaluate({ + toolName: 'run_shell_command', + command, + }); + } + + // --------------------------------------------------------------------------- + // Session rule management + // --------------------------------------------------------------------------- + + /** + * Add a session-level allow rule (in-memory, cleared when the session ends). + * Used when the user clicks "Always allow for this session". + * + * @param raw - The raw rule string, e.g. "Bash(git status)". + */ + addSessionAllowRule(raw: string): void { + if (raw && raw.trim()) { + this.sessionRules.allow.push(parseRule(raw)); + } + } + + /** + * Add a session-level deny rule (in-memory, cleared when the session ends). + */ + addSessionDenyRule(raw: string): void { + if (raw && raw.trim()) { + this.sessionRules.deny.push(parseRule(raw)); + } + } + + /** + * Add a session-level ask rule (in-memory, cleared when the session ends). + */ + addSessionAskRule(raw: string): void { + if (raw && raw.trim()) { + this.sessionRules.ask.push(parseRule(raw)); + } + } + + // --------------------------------------------------------------------------- + // Persistent rule management + // --------------------------------------------------------------------------- + + /** + * Add a single persistent rule to the specified type. + * This modifies the in-memory rule set; the caller is responsible for + * persisting the change to disk (e.g. by writing to settings.json). + * + * @param raw - The raw rule string, e.g. "Bash(git *)" + * @param type - 'allow' | 'ask' | 'deny' + * @returns The parsed rule that was added. + */ + addPersistentRule(raw: string, type: RuleType): PermissionRule { + const rule = parseRule(raw); + this.persistentRules[type].push(rule); + return rule; + } + + /** + * Remove a persistent rule matching the given raw string from the + * specified type. Removes the first match only. + * + * @returns true if a rule was removed, false if no matching rule was found. + */ + removePersistentRule(raw: string, type: RuleType): boolean { + const rules = this.persistentRules[type]; + const idx = rules.findIndex((r) => r.raw === raw); + if (idx !== -1) { + rules.splice(idx, 1); + return true; + } + return false; + } + + // --------------------------------------------------------------------------- + // Default mode + // --------------------------------------------------------------------------- + + /** + * Return the current default approval mode from config. + * This is used by the UI layer when `evaluate()` returns 'default' to + * determine the actual behavior (ask vs allow). + */ + getDefaultMode(): string { + return this.config.getApprovalMode?.() ?? 'default'; + } + + /** + * Update the persistent deny rules (called after migrating settings). + * Replaces the persistent deny rule set entirely. + */ + updatePersistentRules(ruleSet: Partial): void { + if (ruleSet.allow !== undefined) { + this.persistentRules.allow = ruleSet.allow; + } + if (ruleSet.ask !== undefined) { + this.persistentRules.ask = ruleSet.ask; + } + if (ruleSet.deny !== undefined) { + this.persistentRules.deny = ruleSet.deny; + } + } + + // --------------------------------------------------------------------------- + // Listing rules (for /permissions UI) + // --------------------------------------------------------------------------- + + /** + * Return all active rules with their types and scopes, suitable for + * display in the /permissions dialog. + */ + listRules(): RuleWithSource[] { + const result: RuleWithSource[] = []; + + const addRules = ( + rules: PermissionRule[], + type: RuleType, + scope: RuleScope, + ) => { + for (const rule of rules) { + result.push({ rule, type, scope }); + } + }; + + addRules(this.sessionRules.deny, 'deny', 'session'); + addRules(this.persistentRules.deny, 'deny', 'user'); + addRules(this.sessionRules.ask, 'ask', 'session'); + addRules(this.persistentRules.ask, 'ask', 'user'); + addRules(this.sessionRules.allow, 'allow', 'session'); + addRules(this.persistentRules.allow, 'allow', 'user'); + + return result; + } + + /** + * Return a summary of active allow rules (raw strings), including + * both session and persistent rules. Used for telemetry. + */ + getAllowRawStrings(): string[] { + return [ + ...this.sessionRules.allow.map((r) => r.raw), + ...this.persistentRules.allow.map((r) => r.raw), + ]; + } +} diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts new file mode 100644 index 000000000..ae2e8ee39 --- /dev/null +++ b/packages/core/src/permissions/rule-parser.ts @@ -0,0 +1,689 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import os from 'node:os'; +import picomatch from 'picomatch'; +import type { PermissionRule, SpecifierKind } from './types.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Tool name aliases & categories +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Map of known tool name aliases to their canonical names. + * Covers all built-in tools plus common aliases (including Claude Code's "Bash"). + */ +export const TOOL_NAME_ALIASES: Readonly> = { + // Shell tool + run_shell_command: 'run_shell_command', + Shell: 'run_shell_command', + ShellTool: 'run_shell_command', + Bash: 'run_shell_command', // Claude Code compatibility + + // Edit tool — "Edit" is also a meta-category covering edit + write_file + edit: 'edit', + Edit: 'edit', + EditTool: 'edit', + + // Write File tool — also matched by "Edit" meta-category rules + write_file: 'write_file', + WriteFile: 'write_file', + WriteFileTool: 'write_file', + Write: 'write_file', + + // Read File tool — "Read" is also a meta-category covering read_file + grep + glob + list_directory + read_file: 'read_file', + ReadFile: 'read_file', + ReadFileTool: 'read_file', + Read: 'read_file', + + // Grep tool — also matched by "Read" meta-category rules + grep_search: 'grep_search', + Grep: 'grep_search', + GrepTool: 'grep_search', + search_file_content: 'grep_search', // legacy + SearchFiles: 'grep_search', // legacy display name + + // Glob tool — also matched by "Read" meta-category rules + glob: 'glob', + Glob: 'glob', + GlobTool: 'glob', + FindFiles: 'glob', // legacy display name + + // List Directory tool — also matched by "Read" meta-category rules + list_directory: 'list_directory', + ListFiles: 'list_directory', + ListFilesTool: 'list_directory', + ReadFolder: 'list_directory', // legacy display name + + // Memory tool + save_memory: 'save_memory', + SaveMemory: 'save_memory', + SaveMemoryTool: 'save_memory', + + // TodoWrite tool + todo_write: 'todo_write', + TodoWrite: 'todo_write', + TodoWriteTool: 'todo_write', + + // WebFetch tool + web_fetch: 'web_fetch', + WebFetch: 'web_fetch', + WebFetchTool: 'web_fetch', + + // WebSearch tool + web_search: 'web_search', + WebSearch: 'web_search', + WebSearchTool: 'web_search', + + // Task tool + task: 'task', + Task: 'task', + TaskTool: 'task', + + // Skill tool + skill: 'skill', + Skill: 'skill', + SkillTool: 'skill', + + // ExitPlanMode tool + exit_plan_mode: 'exit_plan_mode', + ExitPlanMode: 'exit_plan_mode', + ExitPlanModeTool: 'exit_plan_mode', + + // LSP tool + lsp: 'lsp', + Lsp: 'lsp', + LspTool: 'lsp', + + // Legacy edit tool name + replace: 'edit', + + // Agent (subagent) rules — "Agent" is a category prefix. + // "Agent(Explore)" is parsed with toolName = "Agent" and specifier = "Explore" + Agent: 'Agent', +}; + +/** + * Shell tool canonical names. + */ +const SHELL_TOOL_NAMES = new Set(['run_shell_command']); + +/** + * File-reading tools — "Read" rules apply to all of these (best-effort). + * + * Per Claude Code docs: "Claude makes a best-effort attempt to apply Read rules + * to all built-in tools that read files like Grep and Glob." + */ +const READ_TOOLS = new Set([ + 'read_file', + 'grep_search', + 'glob', + 'list_directory', +]); + +/** + * File-editing tools — "Edit" rules apply to all of these. + * + * Per Claude Code docs: "Edit rules apply to all built-in tools that edit files." + */ +const EDIT_TOOLS = new Set(['edit', 'write_file']); + +/** + * WebFetch tools. + */ +const WEBFETCH_TOOLS = new Set(['web_fetch']); + +// ───────────────────────────────────────────────────────────────────────────── +// Tool name resolution & categorization +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolve a raw tool name or alias to its canonical name. + * Returns the input unchanged if it is not in the alias map + * (e.g. MCP tool names are kept as-is). + */ +export function resolveToolName(rawName: string): string { + return TOOL_NAME_ALIASES[rawName] ?? rawName; +} + +/** + * Determine the specifier kind for a given canonical tool name. + * This tells the matching engine which algorithm to use for the specifier. + */ +export function getSpecifierKind(canonicalToolName: string): SpecifierKind { + if (SHELL_TOOL_NAMES.has(canonicalToolName)) { + return 'command'; + } + if (READ_TOOLS.has(canonicalToolName) || EDIT_TOOLS.has(canonicalToolName)) { + return 'path'; + } + if (WEBFETCH_TOOLS.has(canonicalToolName)) { + return 'domain'; + } + return 'literal'; +} + +/** + * Check whether a given tool (by canonical name) is covered by a rule's tool name, + * taking meta-categories into account. + * + * "Read" → resolves to "read_file", but also covers grep_search, glob, list_directory + * "Edit" → resolves to "edit", but also covers write_file + */ +export function toolMatchesRuleToolName( + ruleToolName: string, + contextToolName: string, +): boolean { + if (ruleToolName === contextToolName) { + return true; + } + // "Read" → covers all READ_TOOLS + if (ruleToolName === 'read_file' && READ_TOOLS.has(contextToolName)) { + return true; + } + // "Edit" → covers all EDIT_TOOLS + if (ruleToolName === 'edit' && EDIT_TOOLS.has(contextToolName)) { + return true; + } + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Rule parsing +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Parse a raw permission rule string into a PermissionRule object. + * + * Supported formats: + * "ToolName" → matches all invocations of the tool + * "ToolName(specifier)" → fine-grained matching via specifier + * + * Tool-specific specifier semantics: + * "Bash(git *)" → shell command glob + * "Read(./secrets/**)" → gitignore-style path match + * "Edit(/src/**\/*.ts)" → gitignore-style path match + * "WebFetch(domain:x.com)" → domain match + * "Agent(Explore)" → subagent name literal match + * "mcp__server__tool" → MCP tool (no specifier needed) + */ +export function parseRule(raw: string): PermissionRule { + const trimmed = raw.trim(); + + // Handle legacy `:*` suffix (deprecated, equivalent to ` *`) + // e.g. "Bash(git:*)" → "Bash(git *)" + const normalized = trimmed.replace(/:(\*)/, ' $1'); + + const openParen = normalized.indexOf('('); + + if (openParen === -1) { + // Simple tool name rule (no specifier) + const canonicalName = resolveToolName(normalized); + return { + raw: trimmed, + toolName: canonicalName, + }; + } + + const toolPart = normalized.substring(0, openParen).trim(); + const specifier = normalized.endsWith(')') + ? normalized.substring(openParen + 1, normalized.length - 1) + : undefined; + + const canonicalName = resolveToolName(toolPart); + const specifierKind = specifier ? getSpecifierKind(canonicalName) : undefined; + + return { + raw: trimmed, + toolName: canonicalName, + specifier, + specifierKind, + }; +} + +/** + * Parse an array of raw rule strings into PermissionRule objects, + * silently skipping any empty entries. + */ +export function parseRules(raws: string[]): PermissionRule[] { + return raws.filter((r) => r && r.trim()).map(parseRule); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shell command matching +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shell operator tokens that act as command boundaries. + * Ordered by length (longest first) for correct multi-char operator detection. + */ +const SHELL_OPERATORS = ['&&', '||', ';;', '|&', '|', ';']; + +/** + * Extract the first simple command from a compound shell command string. + * Stops at the first shell operator boundary (&&, ||, ;, |) that is not + * inside quotes. + * + * Examples: + * "git status && rm -rf /" → "git status" + * "ls -la | grep foo" → "ls -la" + * "echo 'a && b'" → "echo 'a && b'" (inside quotes) + */ +function extractFirstCommand(command: string): string { + let inSingle = false; + let inDouble = false; + let escaped = false; + + for (let i = 0; i < command.length; i++) { + const ch = command[i]!; + + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + continue; + } + if (ch === '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (inSingle || inDouble) { + continue; + } + + // Check for shell operators (longest match first) + for (const op of SHELL_OPERATORS) { + if (command.substring(i, i + op.length) === op) { + return command.substring(0, i).trimEnd(); + } + } + } + + return command; +} + +/** + * Match a shell command against a glob pattern. + * + * Key semantics (from Claude Code docs): + * + * 1. `*` wildcard can appear at any position (head, middle, tail). + * + * 2. **Word boundary rule**: A space before `*` enforces a word boundary. + * - `Bash(ls *)` matches `ls -la` but NOT `lsof` + * - `Bash(ls*)` matches both `ls -la` and `lsof` + * + * 3. **Shell operator awareness**: Patterns don't match across operator + * boundaries. We extract only the first simple command before matching. + * + * 4. Without `*`, uses prefix matching for backward compatibility. + * `Bash(git commit)` matches `git commit -m "test"`. + * + * 5. `Bash(*)` is equivalent to `Bash` and matches any command. + */ +export function matchesCommandPattern( + pattern: string, + command: string, +): boolean { + // Extract only the first simple command (operator awareness) + const firstCmd = extractFirstCommand(command); + + // Special case: lone `*` matches any single command + if (pattern === '*') { + return true; + } + + if (!pattern.includes('*')) { + // No wildcards: prefix matching (backward compat). + // "git commit" matches "git commit" and "git commit -m test" + // but NOT "gitcommit". + return firstCmd === pattern || firstCmd.startsWith(pattern + ' '); + } + + // Build regex from glob pattern with word-boundary semantics. + // + // We walk through the pattern character by character, building a regex. + // When we encounter `*`: + // - If preceded by a space: the space acts as a word boundary before `.*` + // - If preceded by non-space (or at start): `.*` with no boundary constraint + + let regex = '^'; + let pos = 0; + + while (pos < pattern.length) { + const starIdx = pattern.indexOf('*', pos); + if (starIdx === -1) { + // No more wildcards; rest is literal, then allow trailing args + regex += escapeRegex(pattern.substring(pos)); + break; + } + + // Add literal part before the `*` + const literalBefore = pattern.substring(pos, starIdx); + + if (starIdx > 0 && pattern[starIdx - 1] === ' ') { + // Word-boundary wildcard: "ls *" + // The literal includes the trailing space. The `*` matches + // anything after that space (including empty = just "ls"). + // But the key insight: "ls " was already committed, so + // `ls` alone without a trailing space should also match. + // + // Rewrite: literal without trailing space + (space + anything | end) + const literalWithoutTrailingSpace = literalBefore.slice(0, -1); + regex += escapeRegex(literalWithoutTrailingSpace); + regex += '( .*)?'; + } else { + // No word boundary: "ls*" → `ls` followed by anything + regex += escapeRegex(literalBefore); + regex += '.*'; + } + + pos = starIdx + 1; + } + + // If the pattern does NOT end with `*`, the regex already matches exactly. + // If it does end with `*`, the trailing `.*` handles it. + regex += '$'; + + try { + return new RegExp(regex).test(firstCmd); + } catch { + return firstCmd === pattern; + } +} + +/** + * Escape special regex characters. + */ +function escapeRegex(s: string): string { + return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// File path matching (gitignore-style) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolve a path pattern from a permission rule specifier to an absolute + * glob pattern for matching. + * + * Path pattern prefixes (from Claude Code docs): + * + * | Prefix | Meaning | Example | + * |-----------|-----------------------------------|------------------------------| + * | `//path` | Absolute from filesystem root | `//Users/alice/secrets/**` | + * | `~/path` | Relative to home directory | `~/Documents/*.pdf` | + * | `/path` | Relative to project root | `/src/**\/*.ts` | + * | `./path` | Relative to current working dir | `./secrets/**` | + * | `path` | Relative to current working dir | `*.env` | + * + * WARNING: `/Users/alice/file` is NOT an absolute path — it's relative to + * the project root. Use `//Users/alice/file` for absolute paths. + */ +export function resolvePathPattern( + specifier: string, + projectRoot: string, + cwd: string, +): string { + if (specifier.startsWith('//')) { + // Absolute path from filesystem root: `//path` → `/path` + return specifier.substring(1); + } + + if (specifier.startsWith('~/')) { + // Relative to home directory + return path.join(os.homedir(), specifier.substring(2)); + } + + if (specifier.startsWith('/')) { + // Relative to project root (NOT absolute!) + return path.join(projectRoot, specifier.substring(1)); + } + + if (specifier.startsWith('./')) { + // Relative to current working directory + return path.join(cwd, specifier.substring(2)); + } + + // No prefix: relative to current working directory + return path.join(cwd, specifier); +} + +/** + * Match a file path against a gitignore-style path pattern. + * + * Uses picomatch for the actual glob matching, following gitignore semantics: + * - `*` matches files in a single directory (does not cross `/`) + * - `**` matches recursively across directories + * + * @param specifier - The raw specifier from the rule (e.g. "./secrets/**") + * @param filePath - The absolute path of the file being accessed + * @param projectRoot - The project root directory (absolute) + * @param cwd - The current working directory (absolute) + * @returns True if the file path matches the pattern + */ +export function matchesPathPattern( + specifier: string, + filePath: string, + projectRoot: string, + cwd: string, +): boolean { + const resolvedPattern = resolvePathPattern(specifier, projectRoot, cwd); + + // Use picomatch for gitignore-style matching + const isMatch = picomatch(resolvedPattern, { + dot: true, // Match dotfiles (e.g. .env) + nocase: false, // Case-sensitive (filesystem convention) + // Note: do NOT set bash: true — it makes `*` match across directories. + // Default picomatch behavior is gitignore-style: `*` = single dir, `**` = recursive. + }); + + return isMatch(filePath); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Domain matching (for WebFetch) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Match a domain against a WebFetch domain specifier. + * + * Specifier format: `domain:example.com` + * Matches the exact domain or any subdomain. + * + * Examples: + * matchesDomainPattern("domain:example.com", "example.com") → true + * matchesDomainPattern("domain:example.com", "sub.example.com") → true + * matchesDomainPattern("domain:example.com", "notexample.com") → false + */ +export function matchesDomainPattern( + specifier: string, + domain: string, +): boolean { + // Strip the "domain:" prefix if present + const pattern = specifier.startsWith('domain:') + ? specifier.substring(7).trim() + : specifier.trim(); + + if (!pattern || !domain) { + return false; + } + + const normalizedDomain = domain.toLowerCase(); + const normalizedPattern = pattern.toLowerCase(); + + // Exact match + if (normalizedDomain === normalizedPattern) { + return true; + } + + // Subdomain match: "sub.example.com" matches "example.com" + if (normalizedDomain.endsWith('.' + normalizedPattern)) { + return true; + } + + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// MCP tool wildcard matching +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Match an MCP tool name against a pattern that may contain wildcards. + * + * Per Claude Code docs: + * "mcp__puppeteer" matches any tool provided by the puppeteer server + * "mcp__puppeteer__*" wildcard syntax, also matches all tools from the server + * "mcp__puppeteer__puppeteer_navigate" matches only that exact tool + */ +function matchesMcpPattern(pattern: string, toolName: string): boolean { + if (pattern === toolName) { + return true; + } + + // Wildcard: "mcp__server__*" matches all tools from that server + if (pattern.endsWith('__*')) { + const prefix = pattern.slice(0, -1); // "mcp__server__" + return toolName.startsWith(prefix); + } + + // Server-level match: "mcp__puppeteer" matches "mcp__puppeteer__anything" + // Only when the pattern has exactly 2 parts (mcp + server) and the tool has 3+ + const patternParts = pattern.split('__'); + const toolParts = toolName.split('__'); + if ( + patternParts.length === 2 && + toolParts.length >= 3 && + patternParts[0] === toolParts[0] && + patternParts[1] === toolParts[1] + ) { + return true; + } + + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Unified rule matching +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Options for path-based matching, providing the directory context needed + * to resolve relative path patterns. + */ +export interface PathMatchContext { + /** The project root directory (absolute path). */ + projectRoot: string; + /** The current working directory (absolute path). */ + cwd: string; +} + +/** + * Check whether a parsed PermissionRule matches a given context. + * + * Matching logic depends on the tool and specifier type: + * + * 1. **Tool name matching**: + * - "Read" rules also match grep_search, glob, list_directory (meta-category). + * - "Edit" rules also match write_file (meta-category). + * - MCP tools support wildcard patterns (e.g. "mcp__server__*"). + * + * 2. **No specifier**: matches any invocation of the tool. + * + * 3. **With specifier** (depends on specifierKind): + * - `command`: Shell glob matching with word boundary & operator awareness + * - `path`: Gitignore-style file path matching (*, **) + * - `domain`: Domain matching for WebFetch + * - `literal`: Exact string match (for Agent subagent names, etc.) + * + * @param rule - The parsed permission rule + * @param toolName - The canonical tool name being checked + * @param command - Shell command (for Bash rules) + * @param filePath - Absolute file path (for Read/Edit rules) + * @param domain - Domain (for WebFetch rules) + * @param pathContext - Project root and cwd for resolving relative path patterns + */ +export function matchesRule( + rule: PermissionRule, + toolName: string, + command?: string, + filePath?: string, + domain?: string, + pathContext?: PathMatchContext, +): boolean { + const canonicalCtxToolName = resolveToolName(toolName); + + // ── MCP tool matching ──────────────────────────────────────────────── + if ( + rule.toolName.startsWith('mcp__') || + canonicalCtxToolName.startsWith('mcp__') + ) { + return matchesMcpPattern(rule.toolName, canonicalCtxToolName); + } + + // ── Standard tool name matching (with meta-category support) ───────── + if (!toolMatchesRuleToolName(rule.toolName, canonicalCtxToolName)) { + return false; + } + + // ── No specifier → match any invocation of the tool ────────────────── + if (!rule.specifier) { + return true; + } + + // ── Specifier matching (kind-dependent) ────────────────────────────── + const kind = rule.specifierKind ?? getSpecifierKind(rule.toolName); + + switch (kind) { + case 'command': { + if (command === undefined) { + return false; + } + return matchesCommandPattern(rule.specifier, command); + } + + case 'path': { + if (filePath === undefined) { + return false; + } + const ctx = pathContext ?? { + projectRoot: process.cwd(), + cwd: process.cwd(), + }; + return matchesPathPattern( + rule.specifier, + filePath, + ctx.projectRoot, + ctx.cwd, + ); + } + + case 'domain': { + if (domain === undefined) { + return false; + } + return matchesDomainPattern(rule.specifier, domain); + } + + case 'literal': + default: { + // Literal/exact matching (for Agent subagent names, etc.) + if (command !== undefined) { + return command === rule.specifier; + } + return false; + } + } +} diff --git a/packages/core/src/permissions/types.ts b/packages/core/src/permissions/types.ts new file mode 100644 index 000000000..58d5ae389 --- /dev/null +++ b/packages/core/src/permissions/types.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The result of a permission evaluation for a tool or command. + * - 'allow': Auto-approved, no confirmation needed. + * - 'ask': Requires user confirmation before proceeding. + * - 'deny': Blocked; will not run. + * - 'default': No explicit rule matched; falls back to the global approval mode. + */ +export type PermissionDecision = 'allow' | 'ask' | 'deny' | 'default'; + +/** The type of a permission rule. */ +export type RuleType = 'allow' | 'ask' | 'deny'; + +/** The scope/source of a permission rule. */ +export type RuleScope = 'system' | 'user' | 'workspace' | 'session'; + +/** + * The kind of specifier a rule uses, determines which matching algorithm + * to apply. + * + * - 'command': Shell command glob matching (for Bash / run_shell_command) + * - 'path': File path gitignore-style matching (for Read / Edit / Write tools) + * - 'domain': Domain matching with `domain:` prefix (for WebFetch) + * - 'literal': Simple literal equality (fallback for unknown tool types) + */ +export type SpecifierKind = 'command' | 'path' | 'domain' | 'literal'; + +/** + * A parsed permission rule. + * Rules have the form "ToolName" or "ToolName(specifier)". + * + * Examples: + * "Bash" → all shell commands + * "Bash(git *)" → shell commands matching glob + * "Read(./secrets/**)" → file reads matching path pattern + * "Edit(/src/**\/*.ts)" → file edits matching path pattern + * "WebFetch(domain:x.com)" → web fetch matching domain + * "mcp__server__tool" → specific MCP tool + */ +export interface PermissionRule { + /** The original raw rule string as written in config. */ + raw: string; + /** The canonical tool name or category (e.g. "run_shell_command", "Read", "Edit"). */ + toolName: string; + /** + * Optional specifier for fine-grained matching. + * For shell tools: a command pattern (e.g. "git *"). + * For file tools: a path pattern (e.g. "./secrets/**"). + * For WebFetch: a domain pattern (e.g. "domain:example.com"). + */ + specifier?: string; + /** + * The kind of specifier, determines matching algorithm. + * Set automatically during parsing based on the tool name/category. + */ + specifierKind?: SpecifierKind; +} + +/** A complete set of permission rules organized by type. */ +export interface PermissionRuleSet { + allow: PermissionRule[]; + ask: PermissionRule[]; + deny: PermissionRule[]; +} + +/** + * Context for a permission evaluation. + * + * Different fields are relevant depending on the tool type: + * - Shell tools: provide `command` + * - File tools: provide `filePath` + * - WebFetch: provide `domain` + * - Other tools: only `toolName` is needed + */ +export interface PermissionCheckContext { + /** The canonical tool name being checked. */ + toolName: string; + /** + * The shell command being executed (only for Bash / run_shell_command). + */ + command?: string; + /** + * The file path being accessed (only for Read / Edit / Write tools). + * Should be an absolute path for matching against path patterns. + */ + filePath?: string; + /** + * The domain being fetched (only for WebFetch). + */ + domain?: string; +} + +/** A rule with its type and source scope, used for listing rules. */ +export interface RuleWithSource { + rule: PermissionRule; + type: RuleType; + scope: RuleScope; +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 98c8d5cac..b800cc202 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -71,7 +71,11 @@ export class StartSessionEvent implements BaseTelemetryEvent { this.embedding_model = config.getEmbeddingModel(); this.sandbox_enabled = typeof config.getSandbox() === 'string' || !!config.getSandbox(); - this.core_tools_enabled = (config.getCoreTools() ?? []).join(','); + this.core_tools_enabled = ( + config.getPermissionManager?.()?.getAllowRawStrings() ?? + config.getCoreTools() ?? + [] + ).join(','); this.approval_mode = config.getApprovalMode(); this.api_key_enabled = useGemini || useVertex; this.vertex_ai_enabled = useVertex; diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 1f0476866..200ab35c3 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -606,22 +606,19 @@ export function detectCommandSubstitution(command: string): boolean { } /** - * Checks a shell command against security policies and allowlists. + * Checks a shell command against security policies and permission rules. * - * This function operates in one of two modes depending on the presence of - * the `sessionAllowlist` parameter: + * Uses PermissionManager (via config.getPermissionManager()) to evaluate each + * sub-command. The function operates in two modes: * - * 1. **"Default Deny" Mode (sessionAllowlist is provided):** This is the - * strictest mode, used for user-defined scripts like custom commands. - * A command is only permitted if it is found on the global `coreTools` - * allowlist OR the provided `sessionAllowlist`. It must not be on the - * global `excludeTools` blocklist. + * 1. **"Default Deny" Mode (sessionAllowlist is provided):** Used for + * user-defined scripts / custom commands. A command is only permitted if + * it is found in the allow rules OR the provided `sessionAllowlist`. + * Commands not explicitly allowed are treated as a soft denial. * - * 2. **"Default Allow" Mode (sessionAllowlist is NOT provided):** This mode - * is used for direct tool invocations (e.g., by the model). If a strict - * global `coreTools` allowlist exists, commands must be on it. Otherwise, - * any command is permitted as long as it is not on the `excludeTools` - * blocklist. + * 2. **"Default Allow" Mode (sessionAllowlist is NOT provided):** Used for + * direct tool invocations by the model. Commands with a 'deny' decision + * are hard-blocked; 'ask' requires confirmation; all others are allowed. * * @param command The shell command string to validate. * @param config The application configuration. @@ -656,6 +653,69 @@ export function checkCommandPermissions( params: { command: '' }, } as AnyToolInvocation & { params: { command: string } }; + const pm = config.getPermissionManager?.(); + + // When PermissionManager is available, use PM-based evaluation. + if (pm) { + const disallowedCommands: string[] = []; + + for (const cmd of commandsToValidate) { + // 1. Session allowlist always wins (checked first regardless of PM rules) + if (sessionAllowlist) { + invocation.params['command'] = cmd; + const isSessionAllowed = doesToolInvocationMatch( + 'run_shell_command', + invocation, + [...sessionAllowlist].flatMap((c) => + SHELL_TOOL_NAMES.map((name) => `${name}(${c})`), + ), + ); + if (isSessionAllowed) continue; + } + + const decision = pm.isCommandAllowed(cmd); + + if (decision === 'deny') { + return { + allAllowed: false, + disallowedCommands: [cmd], + blockReason: `Command '${cmd}' is blocked by permission rules`, + isHardDenial: true, + }; + } + + if (decision === 'allow') continue; + + // 'ask' → always requires confirmation + if (decision === 'ask') { + disallowedCommands.push(cmd); + continue; + } + + // 'default': behaviour depends on mode + if (sessionAllowlist !== undefined) { + // Default Deny mode: unrecognised commands require confirmation + disallowedCommands.push(cmd); + } + // Default Allow mode: not matched by any rule → allowed + } + + if (disallowedCommands.length > 0) { + return { + allAllowed: false, + disallowedCommands, + blockReason: `Command(s) require confirmation. Disallowed commands: ${disallowedCommands.map((c) => JSON.stringify(c)).join(', ')}`, + isHardDenial: false, + }; + } + + return { allAllowed: true, disallowedCommands: [] }; + } + + // ── Legacy fallback (no PermissionManager) ────────────────────────────── + // Used by SDK consumers that have not yet migrated to the permissions system, + // or in unit tests that mock only getCoreTools/getExcludeTools. + // 1. Blocklist Check (Highest Priority) const excludeTools = config.getExcludeTools() || []; const isWildcardBlocked = SHELL_TOOL_NAMES.some((name) =>