Skip to content

Commit cdcda08

Browse files
author
王璨
committed
feat(permissions): glob tool name matching + Claude Code allow/deny format + Save to Settings
- Support `*` glob wildcard in PermissionRule.tool (e.g. `mcp__lsp__*`) - Parse allow/deny string arrays from settings.json (Claude Code compat) - persistRule() writes to allow/deny arrays instead of rules object array - Add "Save to Settings" option to TUI and Web UI permission dialogs - Wire protocol: add "always_allow_save" decision - Fix Web UI abort not resolving pending permission promise
1 parent 19438b3 commit cdcda08

23 files changed

Lines changed: 686 additions & 39 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-09
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
## Context
2+
3+
当前权限系统的 `PermissionManager.evaluate()` 只做精确工具名匹配(`rule.tool === toolName`)或全局匹配(`rule.tool === "*"`)。`MCPToolDefinition.alwaysLoad` 是 per-tool 级别的布尔值。用户无法用一条配置覆盖整个 MCP server 的工具。
4+
5+
settings.json 权限格式为对象数组 `rules: [{tool, decision, ...}]`,与 Claude Code 的 `allow`/`deny` 字符串数组不兼容。`persistRule()` 写入的也是对象格式。
6+
7+
权限 UI 只有 session 级别持久化(`rememberForSession`),没有文件持久化入口。
8+
9+
## Goals / Non-Goals
10+
11+
**Goals:**
12+
- `PermissionRule.tool` 支持 `*` glob 通配,`mcp__lsp__*` 匹配该 server 全部工具
13+
- settings.json 兼容 Claude Code 的 `allow`/`deny` 字符串数组格式
14+
- TUI 和 Web UI 增加「Always Allow (save)」选项,将带通配的规则写入文件
15+
- 三层权限模型完整:Allow once → Always Allow (session) → Always Allow save (persist)
16+
17+
**Non-Goals:**
18+
- 不支持 `Tool(sub:pattern)` 语法(如 `Bash(git:*)`)——后续迭代
19+
- 不改变 `argPattern` 的匹配逻辑
20+
- 不改变 `denyPatterns`(文件路径 glob)的机制
21+
- session 级别通配授权暂不实现(弹窗中 "Always Allow" 仍只记精确工具名)
22+
- `alwaysLoadNames` 在 ToolRegistry 中的匹配逻辑暂不改动(留待后续)
23+
24+
## Decisions
25+
26+
### 1. Glob 匹配方案:复用现有 `globToRegex` 思路,但工具名分隔符不同
27+
28+
文件路径 glob 的 `globToRegex``/` 为分隔符。工具名用 `_` 做层级分隔,需要调整默认分隔符语义,或者使用简化版:`*` 匹配非空字符序列(`[^_]*`不够,因为工具名内部可能有单下划线),应对齐 Claude Code 行为:`*` 匹配任意字符串。
29+
30+
**决定**:tool glob 中 `*` 匹配任意字符(包括 `_`),等价于正则 `.*`。不引入 `**`。这最符合 Claude Code 的 `mcp__lsp__*` 语义。
31+
32+
实现:`PermissionManager` 内部将 `rule.tool` 中的 glob 模式编译为正则缓存。匹配优先级:精确匹配 → glob 匹配 → `*` 全局。
33+
34+
```typescript
35+
// 伪代码
36+
private compileToolPattern(tool: string): RegExp | null {
37+
if (!tool.includes("*")) return null; // 无通配,精确匹配
38+
const regex = tool.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
39+
return new RegExp(`^${regex}$`);
40+
}
41+
```
42+
43+
### 2. settings.json 格式:双格式并存,读取时合并
44+
45+
```
46+
settings.json:
47+
{
48+
"permissions": {
49+
"allow": ["mcp__lsp__*", "read_file"], // Claude Code 格式
50+
"deny": ["mcp__danger__*"], // Claude Code 格式
51+
"rules": [{ "tool": "bash", "decision": "ask" }] // 现有格式(保留)
52+
}
53+
}
54+
```
55+
56+
- `allow` 数组转换为 `PermissionRuleConfig[]``decision: "allow"`, `priority: 5`
57+
- `deny` 数组转换为 `PermissionRuleConfig[]``decision: "deny"`, `priority: 5`
58+
-`rules` 合并,`rules` 中显式声明的 priority 更高的优先
59+
- 写入时使用 `allow`/`deny` 格式(Claude Code 兼容),但保留 `rules` 字段不删除
60+
61+
### 3. 持久化格式:`allow` 数组
62+
63+
`persistRule()` 写入时:
64+
- 精确工具名 → `allow` 数组中加 `"bash"`
65+
- 通配工具名 → `allow` 数组中加 `"mcp__lsp__*"`
66+
- 不写 `rules` 对象(保持 Claude Code 兼容)
67+
68+
### 4. UI: PermOption 扩展
69+
70+
```
71+
当前 TUI: [Allow] [Always Allow] [Input Idea] [Deny]
72+
当前 Web: [Allow] [Always Allow] [Deny]
73+
74+
改为:
75+
TUI: [Allow] [Always Allow] [Save as Rule] [Deny]
76+
Web: [Allow] [Always Allow] [Save as Rule] [Deny]
77+
```
78+
79+
- `"always_allow"`(session) → `rememberForSession: true`, 精确工具名
80+
- `"always_allow_save"`(persist) → `rememberForSession: true` + `persistRule: { tool: toolName, decision: "allow" }`
81+
- 移除 `"explain"` / `"Input Idea"`(TUI 已有但语义模糊,且与核心权限不相关)
82+
83+
**Wire protocol 扩展**
84+
```
85+
ClientCommand.permission.decision: "allow" | "always_allow" | "always_allow_save" | "deny"
86+
```
87+
88+
### 5. 优先级与冲突解决
89+
90+
-`allow: ["mcp__github__*"]``rules: [{tool: "mcp__github__delete_repo", decision: "deny"}]` 同时存在时,`rules` 中精确匹配的优先级更高
91+
- `evaluate()` 排序逻辑不变:先精确匹配,再 glob 匹配,再 `*`
92+
- `priority` 字段仍生效:高 priority 的规则先匹配
93+
94+
## Risks / Trade-offs
95+
96+
- **Risk**: `*` 匹配过宽,意外放行危险工具 → Mitigation: 用户可配 `deny` 精确覆盖
97+
- **Risk**: `allow`/`deny``rules` 双格式并存导致用户困惑 → Mitigation: persistRule 只写 `allow`,引导单一格式
98+
- **Risk**: regex 编译开销 → Mitigation: 缓存编译结果,规则数量通常 < 50 条
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Why
2+
3+
当前权限系统只支持精确工具名匹配(或全局 `*`),无法用一条规则覆盖整个 MCP server 的工具(如 `mcp__lsp__*`)。同时,`Always Allow` 只能记到会话内存,用户无法持久化到 settings.json 跨会话复用。Claude Code 已支持 `allow`/`deny` 数组 + `*` 通配,dscode 应兼容此格式。
4+
5+
## What Changes
6+
7+
- **工具名通配匹配**`PermissionRule.tool` 支持 `*` glob 模式(`mcp__lsp__*` 匹配该 server 全部工具)
8+
- **Claude Code 权限格式兼容**:settings.json 支持 `allow`/`deny` 字符串数组,与现有 `rules` 对象数组共存
9+
- **持久化权限选项**:TUI 和 Web UI 增加「Always Allow (save)」按钮,将通配规则写入 settings.json
10+
- **三层权限模型**:Allow once(本次调用)→ Always Allow(本会话内存)→ Always Allow save(持久化文件)
11+
- **Modified**: `shared-permission-model``PermOption.value` 增加 `"always_allow_save"` 选项
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
- `tool-name-glob`: 工具名 glob 模式匹配,支持 `mcp__<server>__*` 等通配规则
17+
- `claude-code-permission-format`: settings.json 兼容 Claude Code 的 `allow`/`deny` 数组格式
18+
- `permission-persist-ui`: TUI 和 Web UI 的权限弹窗支持「Always Allow (save)」持久化选项
19+
20+
### Modified Capabilities
21+
- `shared-permission-model`: `PermOption.value` 扩展 `"always_allow_save"``PermissionPromptResult` 支持 `persistRule` 携带通配模式
22+
23+
## Impact
24+
25+
- `src/permissions/manager.ts``evaluate()` 增加 glob 匹配,`persistRule()` 输出 Claude Code 格式
26+
- `src/core/config.ts` — 读取 settings.json 时解析 `allow`/`deny` 数组
27+
- `src/core/types.ts``PermissionRuleConfig.tool` 支持通配模式
28+
- `src/ui/conversation.ts` — TUI `PERM_OPTIONS` 增加 save 选项
29+
- `src/ui/tui-app.ts` — 处理 `always_allow_save` 分支
30+
- `src/ui/shared/types.ts``PermOption.value` / wire protocol 扩展
31+
- `web/src/components/PermissionDialog.tsx` — Web UI 增加 save 按钮
32+
- `src/ui/web/web-backend.ts` — 处理 `always_allow_save` 消息
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Allow/deny string array format
4+
The configuration loader SHALL recognize `allow` and `deny` arrays under `permissions` in settings.json. Each element SHALL be a tool name string (optionally containing `*` glob wildcards). The loader SHALL convert `allow` entries to `PermissionRule` with `decision: "allow"`, `priority: 5`, and `deny` entries to `decision: "deny"`, `priority: 5`.
5+
6+
#### Scenario: Allow array parsed to rules
7+
- **WHEN** settings.json contains `{"permissions": {"allow": ["read_file", "mcp__lsp__*"]}}`
8+
- **THEN** the permission manager SHALL have rules `{tool: "read_file", decision: "allow", priority: 5}` and `{tool: "mcp__lsp__*", decision: "allow", priority: 5}`
9+
10+
#### Scenario: Deny array parsed to rules
11+
- **WHEN** settings.json contains `{"permissions": {"deny": ["Bash(rm:*)", "mcp__danger__*"]}}`
12+
- **THEN** the permission manager SHALL have deny rules for both patterns
13+
14+
#### Scenario: Allow and deny coexist
15+
- **WHEN** settings.json contains both `"allow": ["mcp__lsp__*"]` and `"deny": ["mcp__lsp__delete"]`
16+
- **THEN** both rules SHALL be present; the deny rule SHALL take effect because equal priority rules are checked in order and deny for the exact or more specific match is evaluated first
17+
18+
### Requirement: Rules array coexists with allow/deny
19+
When `permissions.rules` exists alongside `permissions.allow`/`permissions.deny`, all entries SHALL be merged into a single rule set. Rules from the `rules` array SHALL retain their explicitly declared `priority`. Allow/deny entries SHALL use default priority 5.
20+
21+
#### Scenario: Mixed format merging
22+
- **WHEN** settings.json contains `{"permissions": {"allow": ["read_file"], "rules": [{"tool": "bash", "decision": "deny", "priority": 100}]}}`
23+
- **THEN** the merged rule set SHALL contain both `{tool: "read_file", decision: "allow", priority: 5}` and `{tool: "bash", decision: "deny", priority: 100}`
24+
25+
#### Scenario: Higher priority in rules overrides allow array
26+
- **WHEN** `rules` has `{tool: "write_file", decision: "deny", priority: 50}` and `allow` has `["write_file"]`
27+
- **THEN** `write_file` SHALL be denied because the higher priority rule from `rules` matches first
28+
29+
### Requirement: PersistRule writes allow/deny format
30+
When `persistRule()` writes a new permission rule to settings.json, it SHALL use the `allow` or `deny` array format rather than the `rules` object array. The tool name SHALL be written as-is (including any `*` wildcard).
31+
32+
#### Scenario: Persist allow rule to settings
33+
- **WHEN** user selects "Always Allow (save)" for `mcp__lsp__textDocument_hover`
34+
- **THEN** settings.json `permissions.allow` SHALL contain `"mcp__lsp__textDocument_hover"` and `permissions.rules` SHALL be unchanged
35+
36+
#### Scenario: Persist deny rule to settings
37+
- **WHEN** a deny rule with `{tool: "mcp__danger__*", decision: "deny"}` is persisted
38+
- **THEN** settings.json `permissions.deny` SHALL contain `"mcp__danger__*"`
39+
40+
#### Scenario: Existing allow array is appended
41+
- **WHEN** settings.json already has `"allow": ["read_file"]` and a new allow rule for `bash` is persisted
42+
- **THEN** `"allow"` SHALL become `["read_file", "bash"]` (no duplicates)
43+
44+
### Requirement: Empty allow/deny arrays handled gracefully
45+
When settings.json has no `permissions` key or `permissions` is empty, the system SHALL treat it as having no allow/deny rules.
46+
47+
#### Scenario: No permissions key
48+
- **WHEN** settings.json has no `permissions` key
49+
- **THEN** no additional rules are loaded from allow/deny
50+
51+
#### Scenario: Empty permissions block
52+
- **WHEN** settings.json has `"permissions": {}`
53+
- **THEN** no additional rules are loaded
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
## ADDED Requirements
2+
3+
### Requirement: TUI permission dialog includes save option
4+
The TUI permission prompt SHALL present a fourth option "Always Allow (save)" in addition to "Allow", "Always Allow", and "Deny". The "Input Idea" option SHALL be removed. The save option SHALL use key "s" with a distinct color.
5+
6+
#### Scenario: TUI shows save option
7+
- **WHEN** the agent requests permission for tool `mcp__lsp__textDocument_definition`
8+
- **THEN** the TUI SHALL display options: `[Allow] [Always Allow] [Always Allow (save)] [Deny]`
9+
10+
#### Scenario: Save option writes to settings.json
11+
- **WHEN** user selects "Always Allow (save)" for `mcp__lsp__textDocument_definition`
12+
- **THEN** `PermissionPromptResult` SHALL have `decision: "allow"`, `rememberForSession: true`, and `persistRule: {tool: "mcp__lsp__textDocument_definition", decision: "allow"}`
13+
14+
#### Scenario: Save option key binding
15+
- **WHEN** user presses "s" during permission prompt
16+
- **THEN** the "Always Allow (save)" option SHALL be selected
17+
18+
### Requirement: Web UI permission dialog includes save button
19+
The Web `PermissionDialog` component SHALL present a fourth button "Always Allow (save)" alongside "Allow", "Always Allow", and "Deny".
20+
21+
#### Scenario: Web UI shows save button
22+
- **WHEN** a permission prompt is active in the Web UI
23+
- **THEN** four buttons SHALL be displayed: `[Allow] [Always Allow] [Save as Rule] [Deny]`
24+
25+
#### Scenario: Web save button sends persist command
26+
- **WHEN** user clicks "Save as Rule" in Web UI
27+
- **THEN** a `ClientCommand` of type `permission` with `decision: "always_allow_save"` SHALL be sent to the backend
28+
29+
### Requirement: Wire protocol supports always_allow_save
30+
The `ClientCommand.permission.decision` type SHALL include `"always_allow_save"` as a valid value. The `PermOption.value` type in shared types SHALL include `"always_allow_save"`.
31+
32+
#### Scenario: Backend receives always_allow_save
33+
- **WHEN** Web UI sends `{type: "permission", decision: "always_allow_save"}`
34+
- **THEN** the web backend SHALL resolve the permission with `decision: "allow"`, `rememberForSession: true`, and `persistRule: {tool: <current tool>, decision: "allow"}`
35+
36+
### Requirement: Session grants remain exact match
37+
The "Always Allow" (session-level) option SHALL continue to use `sessionGrants` which stores exact tool names only. Session grants SHALL NOT support glob patterns.
38+
39+
#### Scenario: Session grant is exact
40+
- **WHEN** user selects "Always Allow" for `mcp__lsp__textDocument_hover`
41+
- **THEN** only `mcp__lsp__textDocument_hover` is added to session grants; `mcp__lsp__textDocument_definition` is NOT automatically granted
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Canonical PermOption type
4+
The shared module SHALL define a `PermOption` type representing a single permission choice in the UI, with `value`, `label`, and `key` fields.
5+
6+
#### Scenario: Permission options list
7+
- **WHEN** a permission prompt is displayed
8+
- **THEN** the available options are represented as `PermOption[]` with values `"allow" | "always_allow" | "always_allow_save" | "deny"`
9+
10+
### Requirement: Permission types shared between TUI and Web
11+
Both TUI (`conversation.ts`) and Web UI (`App.tsx`) SHALL use the shared `PermissionPrompt`, `PermissionDecision`, and `PermOption` types instead of local definitions.
12+
13+
#### Scenario: TUI uses shared types
14+
- **WHEN** TUI displays a permission prompt
15+
- **THEN** the prompt state uses the shared `PermissionPrompt` type and the options include `"always_allow_save"`
16+
17+
#### Scenario: Web uses shared types
18+
- **WHEN** Web UI displays a permission prompt dialog
19+
- **THEN** the prompt state uses the shared `PermissionPrompt` type and the save button sends `"always_allow_save"` via wire protocol
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Glob wildcard in tool name rules
4+
The `PermissionManager.evaluate()` method SHALL support `*` as a glob wildcard in `rule.tool` values. A `*` SHALL match zero or more of any character (including `_`). The system SHALL compile glob patterns to regular expressions internally and cache them.
5+
6+
#### Scenario: Exact match takes precedence over glob
7+
- **WHEN** `rules` contains `{tool: "mcp__github__delete", decision: "deny", priority: 50}` and `{tool: "mcp__github__*", decision: "allow", priority: 5}`
8+
- **THEN** calling `mcp__github__delete` SHALL be denied (exact match with higher priority wins)
9+
10+
#### Scenario: Glob matches all tools from a server
11+
- **WHEN** `rules` contains `{tool: "mcp__lsp__*", decision: "allow", priority: 5}`
12+
- **THEN** calling `mcp__lsp_textDocument_hover`, `mcp__lsp_textDocument_definition`, and any other tool prefixed with `mcp__lsp_` SHALL be allowed
13+
14+
#### Scenario: Glob does not cross server boundaries
15+
- **WHEN** `rules` contains `{tool: "mcp__lsp__*", decision: "allow"}`
16+
- **THEN** calling `mcp__github__search` SHALL not match (falls through to next rule or default)
17+
18+
#### Scenario: Mid-name wildcard
19+
- **WHEN** `rules` contains `{tool: "mcp__*__search", decision: "allow"}`
20+
- **THEN** calling `mcp__github__search` and `mcp__lsp__search` SHALL both be allowed
21+
22+
#### Scenario: Pure exact match still works
23+
- **WHEN** `rules` contains `{tool: "bash", decision: "deny"}` and no wildcard is present
24+
- **THEN** calling `bash` SHALL be denied via exact match
25+
26+
#### Scenario: Global wildcard still works
27+
- **WHEN** `rules` contains `{tool: "*", decision: "deny"}`
28+
- **THEN** all tools SHALL be denied unless overridden by a higher-priority more-specific rule
29+
30+
### Requirement: Glob patterns are compiled once and cached
31+
`PermissionManager` SHALL compile glob patterns to `RegExp` objects once during construction or rule addition, and reuse the compiled regex on each `evaluate()` call.
32+
33+
#### Scenario: Pattern cache avoids recompilation
34+
- **WHEN** `evaluate()` is called 1000 times with `{tool: "mcp__lsp__*"}`
35+
- **THEN** the glob-to-regex compilation SHALL occur once, not 1000 times
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
## 1. Core: Glob matching in PermissionManager
2+
3+
- [x] 1.1 Add `compileToolPattern()` to `PermissionManager` that compiles `*` glob to `RegExp`, return null for patterns without wildcards
4+
- [x] 1.2 Add `patternCache: Map<string, RegExp>` to `PermissionManager` for compiled regex caching
5+
- [x] 1.3 Modify `evaluate()` to check exact match first, then glob match via compiled patterns, then `*` global
6+
- [x] 1.4 Unit tests for glob matching: server wildcard `mcp__lsp__*`, mid-name `mcp__*__search`, no wildcard exact match, global `*`
7+
8+
## 2. Core: Claude Code allow/deny format in config.ts
9+
10+
- [x] 2.1 In `loadConfig()`, parse `permissions.allow` string array into `PermissionRuleConfig[]` with `decision: "allow", priority: 5`
11+
- [x] 2.2 Parse `permissions.deny` string array into `PermissionRuleConfig[]` with `decision: "deny", priority: 5`
12+
- [x] 2.3 Merge with existing `permissions.rules` array, preserving explicit priorities
13+
- [x] 2.4 Unit tests for config parsing: allow-only, deny-only, mixed formats, empty permissions, no permissions key
14+
15+
## 3. Core: PersistRule writes allow/deny format
16+
17+
- [x] 3.1 Refactor `persistRule()` to write to `permissions.allow` or `permissions.deny` array instead of `rules` array
18+
- [x] 3.2 Ensure no duplicates on append
19+
- [x] 3.3 Preserve existing `rules` array on write (don't delete it)
20+
- [x] 3.4 Unit test: persistRule writes to allow array, handles existing entries, deny writes to deny array
21+
22+
## 4. Shared types: Wire protocol and PermOption extension
23+
24+
- [x] 4.1 Add `"always_allow_save"` to `PermOption.value` in `src/ui/shared/types.ts`
25+
- [x] 4.2 Add `"always_allow_save"` to `ClientCommand.permission.decision` in wire protocol
26+
- [x] 4.3 Remove `"explain"` / `"Input Idea"` from TUI `PermOptionValue` (align with `shared/types.ts`)
27+
28+
## 5. TUI: Permission dialog with save option
29+
30+
- [x] 5.1 Update `PERM_OPTIONS` in `src/ui/conversation.ts`: remove Input Idea, add `{ value: "always_allow_save", label: "Save as Rule", key: "s" }`
31+
- [x] 5.2 Update `applyPermissionOption()` in `src/ui/tui-app.ts` to handle `"always_allow_save"`: resolve with `persistRule: {tool, decision: "allow"}` + `rememberForSession: true`
32+
- [x] 5.3 Handle `persistRule` in the TUI's `resolvePermissionChoice` path (the existing `persistRule` handling in `PermissionManager.check()` should already work for the returned result)
33+
34+
## 6. Web UI: Permission dialog with save button
35+
36+
- [x] 6.1 Update `PermissionDialog.tsx` to add "Save as Rule" button, wired to `onDecision("always_allow_save")`
37+
- [x] 6.2 Update `web-backend.ts` case `"permission"` to handle `"always_allow_save"`: set `persistRule` with the current tool name
38+
- [x] 6.3 Ensure `App.tsx` `onDecision` prop type allows `"always_allow_save"`
39+
40+
## 7. Integration & validation
41+
42+
- [x] 7.1 Manual test: TUI prompt shows 4 options, "always_allow_save" writes to settings.json
43+
- [x] 7.2 Manual test: Web UI prompt shows 4 buttons, "Save as Rule" writes to settings.json
44+
- [x] 7.3 Manual test: settings.json with `"allow": ["mcp__lsp__*"]` grants all LSP tools
45+
- [x] 7.4 Verify no regressions: existing `rules` format still works, session grants still exact-match only
46+
- [x] 7.5 Run `npm test` and `npm run typecheck`

0 commit comments

Comments
 (0)