Skip to content

Commit a402ff0

Browse files
author
王璨
committed
feat(permissions): persist rules to project settings, not user settings
1 parent 4cc0eb1 commit a402ff0

10 files changed

Lines changed: 247 additions & 9 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-05-27
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
## Context
2+
3+
当前权限持久化管线:
4+
5+
```
6+
persistRule(rule)
7+
→ loadUserSettings() // ~/.dscode/settings.json
8+
→ saveUserSettings(merged) // ~/.dscode/settings.json
9+
```
10+
11+
期望管线:
12+
13+
```
14+
persistRule(rule)
15+
→ loadScopedSettings(projectSettingsPath(projectPath)) // $PROJECT/.dscode/settings.json
16+
→ saveJsonSafe(path, merged) // $PROJECT/.dscode/settings.json
17+
```
18+
19+
基础设施已就绪:`projectSettingsPath()``loadScopedSettings()``saveJsonSafe()` 均已存在。仅缺 `saveProjectSettings()` 封装和 `PermissionManager` 拿到 `projectPath`
20+
21+
## Goals / Non-Goals
22+
23+
**Goals:**
24+
- `persistRule` 写入工程级 settings
25+
-`.dscode/` 目录时自动创建
26+
- 与现有加载合并逻辑兼容
27+
28+
**Non-Goals:**
29+
- 不迁移已有用户级旧规则
30+
- 不改 UI 文案
31+
- 不新增用户选项(工程级 vs 用户级选择)
32+
33+
## Decisions
34+
35+
### 1. `PermissionManager` 持有 `projectPath`
36+
37+
**决策**`PermissionManager` 构造函数新增 `projectPath: string` 参数。
38+
39+
```
40+
class PermissionManager {
41+
private projectPath: string;
42+
43+
constructor(
44+
config: PermissionsConfig,
45+
promptUser: PromptUserFn,
46+
projectPath: string, // ← 新增
47+
onBeforePrompt?: () => void,
48+
) { ... }
49+
}
50+
```
51+
52+
**理由**:最简侵入路径。`Harness` 已有 `config.projectPath`,直接透传即可。
53+
54+
**替代方案**:通过回调注入 `(rule) => void` — 增加间接层,无必要。
55+
56+
### 2. `saveProjectSettings()` 封装
57+
58+
**决策**:在 `config.ts` 中新增函数,与 `saveUserSettings()` 对称。
59+
60+
```typescript
61+
export function saveProjectSettings(
62+
projectPath: string,
63+
partial: Record<string, unknown>
64+
): void {
65+
const path = projectSettingsPath(projectPath);
66+
const existing = loadScopedSettings(path);
67+
const merged = { ...existing, ...partial };
68+
for (const [k, v] of Object.entries(partial)) {
69+
if (v === null) delete merged[k];
70+
}
71+
saveJsonSafe(path, merged);
72+
}
73+
```
74+
75+
**理由**:保持 API 一致性,`saveUserSettings``saveProjectSettings` 共享相同的 merge + null-delete 语义。
76+
77+
### 3. 无工程目录时的行为
78+
79+
**决策**`saveJsonSafe` 已包含 `mkdirSync(dir, { recursive: true })``.dscode/` 不存在时自动创建。无需额外处理。
80+
81+
**理由**:已有基础设施覆盖此 edge case,保持简单。
82+
83+
### 4. persistRule 切换到工程级
84+
85+
**决策**`persistRule` 使用 `loadScopedSettings(projectSettingsPath(this.projectPath))` + `saveProjectSettings(this.projectPath, ...)` 替代原有的 `loadUserSettings()` + `saveUserSettings()`
86+
87+
```
88+
private persistRule(rule: PermissionRuleConfig): void {
89+
const settings = loadScopedSettings(projectSettingsPath(this.projectPath));
90+
// ... same logic, but use saveProjectSettings(this.projectPath, ...)
91+
}
92+
```
93+
94+
**理由**:逻辑不变,仅目标路径变化。
95+
96+
## Risks / Trade-offs
97+
98+
- **风险**:纯 REPL 模式(无 project)下写入 `/dev/null` 或根目录 — 实际上 Harness 始终有 `projectPath`(来自 `cliCwd ?? process.cwd()`),这不是问题。
99+
- **风险**:团队协作中,工程师 A 的 allow 规则提交到 git 后影响工程师 B — 这是预期行为,工程级 settings 天然适用于团队共享。
100+
101+
## Open Questions
102+
103+
无。
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## Why
2+
3+
当前权限弹窗的 "Save to settings" 功能将规则持久化到用户级 `~/.dscode/settings.json`。这导致:
4+
5+
- 权限规则与工程无关,无法随仓库共享给团队成员
6+
- 同一用户的不同工程无法拥有独立的权限策略
7+
- 工程级 `permissions.allow/deny` 已支持加载读取,但写入口仍缺失
8+
9+
## What Changes
10+
11+
- **`PermissionManager`**`persistRule()` 改为写入工程级 `$PROJECT/.dscode/settings.json`
12+
- **`config.ts`**:新增 `saveProjectSettings(projectPath, partial)` 函数,与已有的 `saveUserSettings` 对称
13+
- **`harness.ts`**:构造 `PermissionManager` 时传入 `projectPath`
14+
- 无工程目录时自动创建 `.dscode/` 目录(`saveJsonSafe` 已有 `mkdirSync(recursive: true)`
15+
16+
## Capabilities
17+
18+
### Modified Capabilities
19+
20+
- `permission-persist-ui`:persistRule 的目标路径从用户级改为工程级
21+
22+
### New Capabilities
23+
24+
- `permission-project-persist`:工程级 settings 写入规范
25+
26+
## Impact
27+
28+
- UI 无需更改(按钮文案、"Save to settings" 弹窗逻辑不变)
29+
- 已有用户级 `~/.dscode/settings.json` 中的旧规则不动,不迁移
30+
- 加载时工程级优先于用户级(`config.ts:143` 已有合并逻辑),新规则生效无缝
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## ADDED Requirements
2+
3+
### Requirement: persistRule saves to project-level settings.json
4+
The `PermissionManager.persistRule()` method SHALL write permission rules to the project-level `.dscode/settings.json` (via `saveProjectSettings`), not to the user-level `~/.dscode/settings.json`.
5+
6+
#### Scenario: Allow rule saved to project settings
7+
- **WHEN** user selects "Always Allow (save)" for tool `mcp__lsp__textDocument_definition` in project `/home/user/myproject`
8+
- **THEN** `/home/user/myproject/.dscode/settings.json` SHALL contain `permissions.allow` array including `"mcp__lsp__textDocument_definition"`
9+
- **AND** `~/.dscode/settings.json` SHALL NOT be modified
10+
11+
#### Scenario: Deny rule saved to project settings
12+
- **WHEN** user selects "Always Deny (save)" for tool `bash` with pattern `rm -rf`
13+
- **THEN** project `.dscode/settings.json` SHALL contain `permissions.deny` array including `"bash"`
14+
15+
### Requirement: saveProjectSettings merges with existing project settings
16+
The new `saveProjectSettings(projectPath, partial)` function SHALL merge `partial` with existing project settings at `<projectPath>/.dscode/settings.json`. Existing keys not present in `partial` SHALL be preserved. Keys with `null` values in `partial` SHALL be deleted.
17+
18+
#### Scenario: Merge preserves existing keys
19+
- **WHEN** project settings already contains `{"modelId": "deepseek-v4-flash", "skills": ["test-echo"]}`
20+
- **AND** `saveProjectSettings` is called with `{permissions: {allow: ["read_file"]}}`
21+
- **THEN** resulting settings SHALL contain `modelId`, `skills`, AND `permissions`
22+
23+
#### Scenario: Auto-create .dscode directory
24+
- **WHEN** project directory exists but `.dscode/` directory does not
25+
- **AND** `saveProjectSettings` is called
26+
- **THEN** `.dscode/` directory SHALL be created automatically
27+
- **AND** `settings.json` SHALL be written successfully
28+
29+
### Requirement: PermissionManager accepts projectPath
30+
The `PermissionManager` constructor SHALL accept a `projectPath: string` parameter used by `persistRule` to determine the target settings file path.
31+
32+
#### Scenario: Construction with projectPath
33+
- **WHEN** `new PermissionManager(config, promptUser, "/home/user/myproject", onBeforePrompt)` is called
34+
- **THEN** the instance SHALL store `projectPath` and use it in `persistRule` calls
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## 1. config.ts — 新增 saveProjectSettings
2+
3+
- [x] 1.1 在 `src/core/config.ts` 中新增 `saveProjectSettings(projectPath: string, partial: Record<string, unknown>): void`
4+
5+
## 2. PermissionManager — 接入 projectPath
6+
7+
- [x] 2.1 构造函数新增 `projectPath: string` 参数
8+
- [x] 2.2 `persistRule()` 改用 `loadScopedSettings(projectSettingsPath(this.projectPath))` + `saveProjectSettings(this.projectPath, ...)`
9+
10+
## 3. Harness — 透传 projectPath
11+
12+
- [x] 3.1 `src/core/harness.ts``new PermissionManager(...)` 调用传入 `config.projectPath`
13+
14+
## 4. 验证
15+
16+
- [x] 4.1 运行 `npm run typecheck` 确保无类型错误
17+
- [x] 4.2 运行 `npm test` 确保已有测试通过
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## ADDED Requirements
2+
3+
### Requirement: persistRule saves to project-level settings.json
4+
The `PermissionManager.persistRule()` method SHALL write permission rules to the project-level `.dscode/settings.json` (via `saveProjectSettings`), not to the user-level `~/.dscode/settings.json`.
5+
6+
#### Scenario: Allow rule saved to project settings
7+
- **WHEN** user selects "Always Allow (save)" for tool `mcp__lsp__textDocument_definition` in project `/home/user/myproject`
8+
- **THEN** `/home/user/myproject/.dscode/settings.json` SHALL contain `permissions.allow` array including `"mcp__lsp__textDocument_definition"`
9+
- **AND** `~/.dscode/settings.json` SHALL NOT be modified
10+
11+
#### Scenario: Deny rule saved to project settings
12+
- **WHEN** user selects "Always Deny (save)" for tool `bash` with pattern `rm -rf`
13+
- **THEN** project `.dscode/settings.json` SHALL contain `permissions.deny` array including `"bash"`
14+
15+
### Requirement: saveProjectSettings merges with existing project settings
16+
The new `saveProjectSettings(projectPath, partial)` function SHALL merge `partial` with existing project settings at `<projectPath>/.dscode/settings.json`. Existing keys not present in `partial` SHALL be preserved. Keys with `null` values in `partial` SHALL be deleted.
17+
18+
#### Scenario: Merge preserves existing keys
19+
- **WHEN** project settings already contains `{"modelId": "deepseek-v4-flash", "skills": ["test-echo"]}`
20+
- **AND** `saveProjectSettings` is called with `{permissions: {allow: ["read_file"]}}`
21+
- **THEN** resulting settings SHALL contain `modelId`, `skills`, AND `permissions`
22+
23+
#### Scenario: Auto-create .dscode directory
24+
- **WHEN** project directory exists but `.dscode/` directory does not
25+
- **AND** `saveProjectSettings` is called
26+
- **THEN** `.dscode/` directory SHALL be created automatically
27+
- **AND** `settings.json` SHALL be written successfully
28+
29+
### Requirement: PermissionManager accepts projectPath
30+
The `PermissionManager` constructor SHALL accept a `projectPath: string` parameter used by `persistRule` to determine the target settings file path.
31+
32+
#### Scenario: Construction with projectPath
33+
- **WHEN** `new PermissionManager(config, promptUser, "/home/user/myproject", onBeforePrompt)` is called
34+
- **THEN** the instance SHALL store `projectPath` and use it in `persistRule` calls

src/core/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,19 @@ export function saveUserSettings(partial: Record<string, unknown>): void {
8585
saveJsonSafe(path, merged);
8686
}
8787

88+
export function saveProjectSettings(projectPath: string, partial: Record<string, unknown>): void {
89+
const path = projectSettingsPath(projectPath);
90+
const existing = loadScopedSettings(path);
91+
const merged = { ...existing, ...partial };
92+
for (const [k, v] of Object.entries(partial)) {
93+
if (v === null) delete merged[k];
94+
}
95+
saveJsonSafe(path, merged);
96+
}
97+
98+
8899
export function saveUserConfig(partial: Record<string, unknown>): void {
100+
89101
const path = userConfigPath();
90102
const existing = loadUserCommandConfig();
91103
const merged = { ...existing, ...partial };

src/core/harness.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export class Harness implements HarnessAPI {
6767
this.permissionManager = new PermissionManager(
6868
config.permissions,
6969
(toolName, preview, args) => this.ui.getPromptPermission()(toolName, preview, args),
70+
config.projectPath,
7071
() => {},
7172
);
7273
this.imagePipeline = new ImagePipeline({

src/permissions/manager.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { PermissionDecision, PermissionRule, PermissionRuleConfig, PermissionsConfig, PromptUserFn } from "../core/types.js";
2-
import { loadUserSettings, saveUserSettings } from "../core/config.js";
2+
import { loadScopedSettings, projectSettingsPath, saveProjectSettings } from "../core/config.js";
33
import { DEFAULT_RULES } from "./rules.js";
44

55
function globToRegex(pattern: string): RegExp {
@@ -20,7 +20,10 @@ export class PermissionManager {
2020
private onBeforePrompt?: () => void;
2121
private toolPatternCache = new Map<string, RegExp | null>();
2222

23-
constructor(config: PermissionsConfig, promptUser: PromptUserFn, onBeforePrompt?: () => void) {
23+
private projectPath: string;
24+
25+
constructor(config: PermissionsConfig, promptUser: PromptUserFn, projectPath: string, onBeforePrompt?: () => void) {
26+
this.projectPath = projectPath;
2427
this.defaultDecision = config.defaultDecision;
2528
this.promptUser = promptUser;
2629
this.onBeforePrompt = onBeforePrompt;
@@ -108,15 +111,15 @@ export class PermissionManager {
108111
}
109112

110113
private persistRule(rule: PermissionRuleConfig): void {
111-
const settings = loadUserSettings();
114+
const settings = loadScopedSettings(projectSettingsPath(this.projectPath));
112115
const permissions = ((settings.permissions as Record<string, unknown> | undefined) ?? {});
113116

114117
if (rule.decision === "allow") {
115118
const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
116119
if (!allow.includes(rule.tool)) {
117120
allow.push(rule.tool);
118121
}
119-
saveUserSettings({
122+
saveProjectSettings(this.projectPath, {
120123
...settings,
121124
permissions: {
122125
...permissions,
@@ -128,7 +131,7 @@ export class PermissionManager {
128131
if (!deny.includes(rule.tool)) {
129132
deny.push(rule.tool);
130133
}
131-
saveUserSettings({
134+
saveProjectSettings(this.projectPath, {
132135
...settings,
133136
permissions: {
134137
...permissions,

tests/permissions/manager.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,16 +254,18 @@ describe("PermissionManager", () => {
254254
const pm = new PermissionManager(defaultConfig, async () => ({
255255
decision: "allow",
256256
rememberForSession: false,
257-
}), () => { called = true; });
257+
}), "/tmp/test-project", () => { called = true; });
258258
await pm.check({
259259
toolCall: { name: "write_file" },
260260
args: { path: "/tmp/test.txt", content: "hello" },
261261
});
262262
expect(called).toBe(true);
263263
});
264264

265-
it("persists a saved allow rule to user settings", async () => {
265+
it("persists a saved allow rule to project settings", async () => {
266266
const root = mkdtempSync(join(tmpdir(), "dscode-permissions-"));
267+
const projectDir = join(root, "project");
268+
mkdirSync(projectDir, { recursive: true });
267269
const configHome = join(root, "home");
268270
mkdirSync(configHome, { recursive: true });
269271
process.env.DSCODE_CONFIG_HOME = configHome;
@@ -278,15 +280,15 @@ describe("PermissionManager", () => {
278280
reason: "saved from permission prompt",
279281
priority: 20,
280282
},
281-
}));
283+
}), projectDir);
282284

283285
const result = await pm.check({
284286
toolCall: { name: "bash" },
285287
args: { command: "npm test" },
286288
});
287289

288290
expect(result).toBeUndefined();
289-
const saved = JSON.parse(readFileSync(join(configHome, "settings.json"), "utf8"));
291+
const saved = JSON.parse(readFileSync(join(projectDir, ".dscode", "settings.json"), "utf8"));
290292
expect(saved.permissions.allow).toHaveLength(1);
291293
expect(saved.permissions.allow[0]).toBe("bash");
292294

0 commit comments

Comments
 (0)