@zhushanwen/pi-permission 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +455 -0
  2. package/index.ts +2 -0
  3. package/package.json +52 -0
  4. package/src/__tests__/approval.test.ts +456 -0
  5. package/src/__tests__/commands.test.ts +487 -0
  6. package/src/__tests__/config.test.ts +229 -0
  7. package/src/__tests__/e2e-modes.test.ts +253 -0
  8. package/src/__tests__/extension.test.ts +170 -0
  9. package/src/__tests__/index-integration.test.ts +282 -0
  10. package/src/__tests__/model-picker.test.ts +371 -0
  11. package/src/__tests__/pipeline.test.ts +976 -0
  12. package/src/__tests__/production.test.ts +102 -0
  13. package/src/__tests__/rule-editor-component.test.ts +475 -0
  14. package/src/__tests__/rule-editor.test.ts +252 -0
  15. package/src/__tests__/rule-templates.test.ts +264 -0
  16. package/src/__tests__/statusline.test.ts +221 -0
  17. package/src/__tests__/types.test.ts +59 -0
  18. package/src/approval.ts +352 -0
  19. package/src/ast/__tests__/analyzer.test.ts +262 -0
  20. package/src/ast/__tests__/loader.test.ts +136 -0
  21. package/src/ast/analyzer.ts +326 -0
  22. package/src/ast/index.ts +7 -0
  23. package/src/ast/loader.ts +113 -0
  24. package/src/classifier/__tests__/classifier.test.ts +285 -0
  25. package/src/classifier/__tests__/json-parser.test.ts +118 -0
  26. package/src/classifier/__tests__/model-resolver.test.ts +173 -0
  27. package/src/classifier/__tests__/prompt.test.ts +59 -0
  28. package/src/classifier/classifier.ts +242 -0
  29. package/src/classifier/index.ts +28 -0
  30. package/src/classifier/json-parser.ts +127 -0
  31. package/src/classifier/model-resolver.ts +295 -0
  32. package/src/classifier/prompt.ts +52 -0
  33. package/src/commands.ts +234 -0
  34. package/src/config.ts +216 -0
  35. package/src/index.ts +291 -0
  36. package/src/model-picker.ts +398 -0
  37. package/src/pipeline.ts +541 -0
  38. package/src/production.ts +102 -0
  39. package/src/rule-editor-component.ts +883 -0
  40. package/src/rule-editor.ts +262 -0
  41. package/src/rule-templates.ts +332 -0
  42. package/src/rules/__tests__/builtins.test.ts +614 -0
  43. package/src/rules/__tests__/index.test.ts +26 -0
  44. package/src/rules/__tests__/matcher.test.ts +378 -0
  45. package/src/rules/__tests__/wildcard.test.ts +84 -0
  46. package/src/rules/builtins.ts +680 -0
  47. package/src/rules/index.ts +27 -0
  48. package/src/rules/matcher.ts +160 -0
  49. package/src/rules/wildcard.ts +43 -0
  50. package/src/statusline.ts +226 -0
  51. package/src/types.ts +249 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * AI Classifier prompt 构造(I4 classifyRisk 用)
3
+ *
4
+ * - CLASSIFIER_SYSTEM_PROMPT:约束 LLM 仅返回严格 JSON
5
+ * - buildClassifierUserPrompt(ctx):把待审工具调用拼成 user message
6
+ *
7
+ * 设计原则:prompt 短(控制成本/延迟)+ 强约束输出格式(配合 json-parser 三层容错)。
8
+ */
9
+
10
+ import type { ToolInvocationContext } from "../types.js";
11
+
12
+ /**
13
+ * System prompt:约束 LLM 作为权限风险分类器。
14
+ *
15
+ * 要求输出 JSON `{ outcome, risk_level, reasoning, confidence }`:
16
+ * - outcome ∈ {allow, deny, ask}
17
+ * - risk_level ∈ {low, medium, high}
18
+ * - reasoning:简短人类可读理由(英文/中文均可,≤1 句)
19
+ * - confidence:0-1 浮点
20
+ *
21
+ * 故意保持 ~80 token,降低首次调用延迟与成本。
22
+ */
23
+ export const CLASSIFIER_SYSTEM_PROMPT = `You are a security risk classifier for tool invocations in an AI coding agent.
24
+ Evaluate the given tool invocation for destructive or unsafe operations.
25
+
26
+ Reply with ONLY a JSON object (no markdown, no prose before/after):
27
+ {"outcome": "allow" | "deny" | "ask", "risk_level": "low" | "medium" | "high", "reasoning": "one short sentence", "confidence": 0.0-1.0}
28
+
29
+ Rules:
30
+ - allow: safe operations (read-only commands, writing to project directory, git status/diff/log, ls, cat, echo, grep, find)
31
+ - deny: clearly destructive AND irreversible (rm -rf /, mkfs, force push to main, drop database, format disk)
32
+ - ask: potentially dangerous or system-wide changes (rm with recursion, sudo, writing to system dirs like /etc, network operations, deleting multiple files)
33
+ - For file writes: allow if writing to user's project/cwd directory; ask if writing to system dirs or sensitive paths (~/.ssh, /etc)
34
+ - confidence = your certainty in the outcome (0.0 = guessing, 1.0 = certain)`;
35
+
36
+ /**
37
+ * 构造 user prompt:把 ToolInvocationContext 的关键字段拼成可读文本。
38
+ *
39
+ * 缺省字段用 "(none)" 占位,保证 LLM 看到稳定结构。
40
+ */
41
+ export function buildClassifierUserPrompt(ctx: ToolInvocationContext): string {
42
+ const lines: string[] = [
43
+ `tool: ${ctx.toolName}`,
44
+ `command: ${ctx.command ?? "(none)"}`,
45
+ `path: ${ctx.path ?? "(none)"}`,
46
+ `cwd: ${ctx.cwd}`,
47
+ ];
48
+ if (ctx.agentName) {
49
+ lines.push(`agent: ${ctx.agentName}`);
50
+ }
51
+ return `Evaluate this tool invocation:\n${lines.join("\n")}`;
52
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * /permission 命令
3
+ *
4
+ * W1 版本:文本菜单模式(ctx.ui.notify 显示当前模式 + 提示用 /permission <mode> 切换)。
5
+ * 交互式方向键菜单(ctx.ui.custom + 自定义 Component)留待 W6 或后续迭代。
6
+ *
7
+ * 用法:
8
+ * /permission 显示当前模式和可用模式列表
9
+ * /permission yolo 切换到 yolo 模式
10
+ * /permission auto 切换到 auto 模式
11
+ * /permission approve 切换到 approve 模式
12
+ * /permission strict 切换到 strict 模式
13
+ * /permission status 显示当前配置详情
14
+ */
15
+
16
+ import type { ResolvedModelEntry } from "./classifier/model-resolver.js";
17
+ import { type ModelPickerContext,pickModelViaOverlay } from "./model-picker.js";
18
+ import { type RuleEditorContext,type RuleEditorResult } from "./rule-editor.js";
19
+ import { applyOps } from "./rule-templates.js";
20
+ import {
21
+ isValidPermissionMode,
22
+ MODE_DESCRIPTIONS,
23
+ MODE_LABELS,
24
+ PERMISSION_MODES,
25
+ type PermissionConfig,
26
+ type PermissionMode,
27
+ } from "./types.js";
28
+
29
+ /** 命令处理器返回的文本消息(由 ctx.ui.notify 显示) */
30
+ export function handlePermissionCommand(
31
+ args: string | undefined,
32
+ config: PermissionConfig,
33
+ onSave: (config: PermissionConfig) => { success: boolean; error?: string },
34
+ ): string {
35
+ const trimmed = (args ?? "").trim();
36
+
37
+ // 无参数:显示当前模式 + 可用模式
38
+ if (!trimmed) {
39
+ return formatStatusMessage(config);
40
+ }
41
+
42
+ // status 子命令:详细配置
43
+ if (trimmed === "status") {
44
+ return formatDetailedStatus(config);
45
+ }
46
+
47
+ // 切换模式
48
+ if (isValidPermissionMode(trimmed)) {
49
+ return switchMode(trimmed, config, onSave);
50
+ }
51
+
52
+ // 未知参数
53
+ return `[pi-permission] Unknown mode '${trimmed}'. Available: ${PERMISSION_MODES.join(", ")}. Usage: /permission [mode|status|rule|model]`;
54
+ }
55
+
56
+ // ──────────────────────── 消息格式化 ────────────────────────
57
+
58
+ function formatStatusMessage(config: PermissionConfig): string {
59
+ const lines: string[] = [];
60
+ lines.push("[pi-permission] Current mode: " + modeHighlight(config.mode));
61
+ lines.push("");
62
+ lines.push("Available modes (by strictness, low → high):");
63
+ for (const mode of PERMISSION_MODES) {
64
+ const marker = mode === config.mode ? "►" : " ";
65
+ lines.push(` ${marker} ${mode.padEnd(8)} — ${MODE_DESCRIPTIONS[mode]}`);
66
+ }
67
+ lines.push("");
68
+ lines.push("Switch with: /permission <mode>");
69
+ return lines.join("\n");
70
+ }
71
+
72
+ function formatDetailedStatus(config: PermissionConfig): string {
73
+ const lines: string[] = [];
74
+ lines.push("[pi-permission] Configuration:");
75
+ lines.push(` mode: ${modeHighlight(config.mode)}`);
76
+ lines.push(` enabled: ${config.enabled}`);
77
+ lines.push(` classifier:`);
78
+ lines.push(` enabled: ${config.classifier.enabled}`);
79
+ lines.push(` model: ${config.classifier.model}`);
80
+ lines.push(` timeout: ${config.classifier.timeout}s`);
81
+ lines.push(` autoApproveLow: ${config.classifier.autoApproveLowRisk}`);
82
+ lines.push(` autoDenyHigh: ${config.classifier.autoDenyHighRisk}`);
83
+ lines.push(` userRules: ${config.userRules.length} rule(s)`);
84
+ return lines.join("\n");
85
+ }
86
+
87
+ function switchMode(
88
+ mode: PermissionMode,
89
+ config: PermissionConfig,
90
+ onSave: (config: PermissionConfig) => { success: boolean; error?: string },
91
+ ): string {
92
+ if (mode === config.mode) {
93
+ return `[pi-permission] Already in ${MODE_LABELS[mode]} mode.`;
94
+ }
95
+
96
+ const newConfig: PermissionConfig = {
97
+ mode,
98
+ enabled: config.enabled,
99
+ classifier: { ...config.classifier },
100
+ userRules: config.userRules.map((r) => ({ ...r })),
101
+ };
102
+ const result = onSave(newConfig);
103
+
104
+ if (!result.success) {
105
+ return `[pi-permission] Failed to switch to ${MODE_LABELS[mode]} mode: ${result.error ?? "unknown error"}`;
106
+ }
107
+
108
+ return `[pi-permission] Switched to ${MODE_LABELS[mode]} mode.\n ${MODE_DESCRIPTIONS[mode]}`;
109
+ }
110
+
111
+ function modeHighlight(mode: PermissionMode): string {
112
+ // 纯文本标记(项目规范禁 emoji),用括号标注
113
+ return `${MODE_LABELS[mode]} (${mode})`;
114
+ }
115
+
116
+ // ──────────────────────── /permission model(W7) ────────────────────────
117
+
118
+ /** handlePermissionModelCommand 的依赖(DI 便于测试 mock)。 */
119
+ export interface PermissionModelCommandDeps {
120
+ /** 列出可用模型(按 provider 分组)。 */
121
+ listModels: () => Map<string, ResolvedModelEntry[]>;
122
+ /** 保存新配置,返回 {success, error?}。 */
123
+ save: (config: PermissionConfig) => { success: boolean; error?: string };
124
+ }
125
+
126
+ /**
127
+ * /permission model 命令:overlay 选择 classifier model(provider/model 或 auto)。
128
+ *
129
+ * 流程:
130
+ * 1. listModels:拿可用模型 Map。空 → 降级 notify + return。
131
+ * 2. pickModelViaOverlay:TUI/RPC/headless 三模式分发。
132
+ * 3. 写回:save(newConfig)。失败 → notify error。
133
+ *
134
+ * 用依赖注入(deps 参数)便于测试 mock listModels/save。
135
+ * ctx 用 ModelPickerContext 子集(mode + ui.notify/custom/select)。
136
+ */
137
+ export async function handlePermissionModelCommand(
138
+ ctx: ModelPickerContext,
139
+ config: PermissionConfig,
140
+ deps: PermissionModelCommandDeps,
141
+ ): Promise<void> {
142
+ const current = config.classifier.model;
143
+
144
+ // 1. listModels
145
+ const models = deps.listModels();
146
+ if (models.size === 0) {
147
+ ctx.ui.notify(
148
+ "[pi-permission] No available models. Configure ~/.pi/agent/models.json first.",
149
+ "warning",
150
+ );
151
+ return;
152
+ }
153
+
154
+ // 2. pickModelViaOverlay
155
+ const selected = await pickModelViaOverlay(ctx, current, models);
156
+ if (selected === undefined) {
157
+ ctx.ui.notify("[pi-permission] Model selection cancelled.", "info");
158
+ return;
159
+ }
160
+
161
+ // 3. 写回(仅改 classifier.model,保留其余字段)
162
+ const newConfig: PermissionConfig = {
163
+ mode: config.mode,
164
+ enabled: config.enabled,
165
+ classifier: { ...config.classifier, model: selected },
166
+ userRules: config.userRules.map((r) => ({ ...r })),
167
+ };
168
+ const result = deps.save(newConfig);
169
+ if (!result.success) {
170
+ ctx.ui.notify(
171
+ `[pi-permission] Failed to save: ${result.error ?? "unknown error"}`,
172
+ "error",
173
+ );
174
+ return;
175
+ }
176
+ ctx.ui.notify(`[pi-permission] AI classifier model set to: ${selected}`, "info");
177
+ }
178
+
179
+ // ──────────────────────── /permission rule(W8) ────────────────────────
180
+
181
+ /** handlePermissionRuleCommand 的依赖(DI 便于测试 mock)。 */
182
+ export interface PermissionRuleCommandDeps {
183
+ /** 保存新配置,返回 {success, error?}。 */
184
+ save: (config: PermissionConfig) => { success: boolean; error?: string };
185
+ /** 规则编辑 overlay(注入便于测试 mock)。 */
186
+ editRulesViaOverlay: (
187
+ ctx: RuleEditorContext,
188
+ initialRules: readonly import("./types.js").Rule[],
189
+ sessionIdCounter: () => string,
190
+ rpcDeps?: import("./rule-editor.js").RuleEditorRpcDeps,
191
+ ) => Promise<RuleEditorResult>;
192
+ }
193
+
194
+ /**
195
+ * /permission rule 命令:overlay CRUD 编辑 userRules。
196
+ *
197
+ * 流程:
198
+ * 1. editRulesViaOverlay:TUI/RPC/headless 三模式分发。
199
+ * 2. ops 非空 → applyOps + saveConfig + notify。
200
+ * 3. 空 → notify no changes。
201
+ */
202
+ export async function handlePermissionRuleCommand(
203
+ ctx: RuleEditorContext,
204
+ config: PermissionConfig,
205
+ sessionIdCounter: () => string,
206
+ deps: PermissionRuleCommandDeps,
207
+ ): Promise<void> {
208
+ // 1. editRulesViaOverlay
209
+ const ops = await deps.editRulesViaOverlay(ctx, config.userRules, sessionIdCounter);
210
+
211
+ if (ops === undefined || ops.length === 0) {
212
+ ctx.ui.notify("[pi-permission] No changes applied.", "info");
213
+ return;
214
+ }
215
+
216
+ // 2. applyOps + save
217
+ const newUserRules = applyOps([...config.userRules], ops);
218
+ const newConfig: PermissionConfig = {
219
+ mode: config.mode,
220
+ enabled: config.enabled,
221
+ classifier: { ...config.classifier },
222
+ userRules: newUserRules,
223
+ };
224
+ const result = deps.save(newConfig);
225
+ if (!result.success) {
226
+ ctx.ui.notify(
227
+ `[pi-permission] Failed to save rules: ${result.error ?? "unknown error"}`,
228
+ "error",
229
+ );
230
+ return;
231
+ }
232
+
233
+ ctx.ui.notify(`[pi-permission] ${ops.length} change(s) applied.`, "info");
234
+ }
package/src/config.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * 配置加载 / 保存 / mtime 缓存
3
+ *
4
+ * 对应 I6 loadAndWatchConfig。参考 pi-permission-system extension-config.ts。
5
+ * 文件位置:~/.pi/agent/permission-config.json(支持 PI_CODING_AGENT_DIR 覆盖)。
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+
12
+ import {
13
+ DEFAULT_CLASSIFIER_CONFIG,
14
+ DEFAULT_CONFIG,
15
+ isValidPermissionMode,
16
+ type ClassifierConfig,
17
+ type PermissionConfig,
18
+ type Rule,
19
+ } from "./types.js";
20
+
21
+ // ──────────────────────── 路径解析 ────────────────────────
22
+
23
+ /** PI_CODING_AGENT_DIR 环境变量覆盖 ~/.pi/agent 基础路径 */
24
+ function getAgentDir(): string {
25
+ const override = process.env.PI_CODING_AGENT_DIR?.trim();
26
+ if (override) return override;
27
+ return join(homedir(), ".pi", "agent");
28
+ }
29
+
30
+ /** 配置文件完整路径 */
31
+ export function getConfigPath(): string {
32
+ return join(getAgentDir(), "permission-config.json");
33
+ }
34
+
35
+ // ──────────────────────── mtime 缓存 ────────────────────────
36
+
37
+ interface CacheEntry {
38
+ mtimeMs: number;
39
+ size: number;
40
+ config: PermissionConfig;
41
+ }
42
+
43
+ /** 深拷贝 config(防止调用方修改污染缓存) */
44
+ function cloneConfig(config: PermissionConfig): PermissionConfig {
45
+ return {
46
+ mode: config.mode,
47
+ enabled: config.enabled,
48
+ classifier: { ...config.classifier },
49
+ userRules: config.userRules.map((r) => ({ ...r })),
50
+ };
51
+ }
52
+
53
+ /** 模块级缓存:path → {mtimeMs, config}。单进程多 session 共享读缓存是安全的(配置只读) */
54
+ const configCache = new Map<string, CacheEntry>();
55
+
56
+ /** 测试用:清空缓存 */
57
+ export function clearConfigCache(): void {
58
+ configCache.clear();
59
+ }
60
+
61
+ // ──────────────────────── 归一化 ────────────────────────
62
+
63
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
66
+
67
+ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
68
+ const record = isPlainObject(raw) ? raw : {};
69
+ const timeout = Number(record.timeout);
70
+ return {
71
+ enabled: record.enabled !== false,
72
+ model: typeof record.model === "string" && record.model.length > 0 ? record.model : DEFAULT_CLASSIFIER_CONFIG.model,
73
+ timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_CLASSIFIER_CONFIG.timeout,
74
+ autoApproveLowRisk: record.autoApproveLowRisk !== false,
75
+ autoDenyHighRisk: record.autoDenyHighRisk !== false,
76
+ };
77
+ }
78
+
79
+ function normalizeRule(raw: unknown, fallbackId: string): Rule | null {
80
+ if (!isPlainObject(raw)) return null;
81
+ const tool = typeof raw.tool === "string" ? raw.tool : "*";
82
+ const pattern = typeof raw.pattern === "string" ? raw.pattern : "*";
83
+ const action = raw.action;
84
+ if (action !== "allow" && action !== "deny" && action !== "ask") return null;
85
+ const source = raw.source === "user" ? "user" : raw.source === "builtin-safe" ? "builtin-safe" : raw.source === "builtin-danger" ? "builtin-danger" : "user";
86
+ const id = typeof raw.id === "string" && raw.id.length > 0 ? raw.id : fallbackId;
87
+ const description = typeof raw.description === "string" ? raw.description : undefined;
88
+ return { id, tool, pattern, action, source, ...(description !== undefined ? { description } : {}) };
89
+ }
90
+
91
+ function normalizeConfig(raw: unknown): PermissionConfig {
92
+ const record = isPlainObject(raw) ? raw : {};
93
+ const mode = isValidPermissionMode(record.mode) ? record.mode : DEFAULT_CONFIG.mode;
94
+ const enabled = record.enabled !== false;
95
+ const classifier = normalizeClassifierConfig(record.classifier);
96
+ const userRulesRaw = Array.isArray(record.userRules) ? record.userRules : [];
97
+ const userRules = userRulesRaw
98
+ .map((r, i) => normalizeRule(r, `user-${i + 1}`))
99
+ .filter((r): r is Rule => r !== null);
100
+ return { mode, enabled, classifier, userRules };
101
+ }
102
+
103
+ // ──────────────────────── 默认配置文件创建 ────────────────────────
104
+
105
+ function createDefaultConfigContent(): string {
106
+ return `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`;
107
+ }
108
+
109
+ function ensureConfigFile(configPath: string, onWarning?: (msg: string) => void): void {
110
+ if (existsSync(configPath)) return;
111
+ try {
112
+ mkdirSync(dirname(configPath), { recursive: true });
113
+ writeFileSync(configPath, createDefaultConfigContent(), { encoding: "utf-8", mode: 0o600 });
114
+ } catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ onWarning?.(`[pi-permission] Failed to create default config at '${configPath}': ${message}`);
117
+ }
118
+ }
119
+
120
+ // ──────────────────────── 加载(带 mtime 缓存) ────────────────────────
121
+
122
+ /**
123
+ * 加载配置,文件未变化时返回缓存。
124
+ *
125
+ * @param configPath 配置文件路径(默认 getConfigPath())
126
+ * @param onWarning 非致命问题(创建失败、解析失败)的警告回调
127
+ */
128
+ export function loadAndWatchConfig(
129
+ configPath: string = getConfigPath(),
130
+ onWarning?: (msg: string) => void,
131
+ ): PermissionConfig {
132
+ ensureConfigFile(configPath, onWarning);
133
+
134
+ let stat;
135
+ try {
136
+ stat = statSync(configPath);
137
+ } catch {
138
+ // 文件不可 stat(权限问题/被删除)→ 用缓存或默认
139
+ const cached = configCache.get(configPath);
140
+ return cached ? cloneConfig(cached.config) : cloneConfig(DEFAULT_CONFIG);
141
+ }
142
+
143
+ const cached = configCache.get(configPath);
144
+ // mtime + size 双 key:防止 APFS 等文件系统 mtime 精度截断导致快速连续保存后缓存失效。
145
+ //
146
+ // 已知 limitation(m5):mtime + size 不是内容指纹,「同毫秒同字节大小但内容不同」的写入
147
+ // (如交换两条等长 userRules 的顺序)会误命中缓存返回旧 config。完整消除需内容哈希(如 sha256),
148
+ // 但每次 load 都算哈希成本过高(config 可能较大),且 permission-config 写入频率低(用户手动编辑
149
+ // 或 /permission 命令)、mtime 变化的概率远高于同毫秒同大小写不同内容,故权衡采用 mtime+size。
150
+ // saveConfig 已在写后立即用新 stat 更新缓存(见下方 saveConfig),覆盖最常见的「写后读」竞态。
151
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
152
+ return cloneConfig(cached.config);
153
+ }
154
+
155
+ try {
156
+ const raw = readFileSync(configPath, "utf-8");
157
+ const parsed: unknown = JSON.parse(raw);
158
+ const config = normalizeConfig(parsed);
159
+ configCache.set(configPath, { mtimeMs: stat.mtimeMs, size: stat.size, config });
160
+ return cloneConfig(config);
161
+ } catch (error) {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ onWarning?.(`[pi-permission] Config parse failed at '${configPath}', using default: ${message}`);
164
+ const fallback = { ...DEFAULT_CONFIG, classifier: { ...DEFAULT_CLASSIFIER_CONFIG } };
165
+ // 解析失败也更新缓存 mtime+size,避免每次都重读损坏文件
166
+ configCache.set(configPath, { mtimeMs: stat.mtimeMs, size: stat.size, config: fallback });
167
+ return cloneConfig(fallback);
168
+ }
169
+ }
170
+
171
+ // ──────────────────────── 保存(原子写) ────────────────────────
172
+
173
+ /**
174
+ * 保存配置(原子写:tmp 文件 + rename)。
175
+ *
176
+ * @returns 成功返回 {success:true};失败返回 {success:false, error}
177
+ */
178
+ export function saveConfig(
179
+ config: PermissionConfig,
180
+ configPath: string = getConfigPath(),
181
+ ): { success: boolean; error?: string } {
182
+ // 信任调用方传入的已类型化对象,不重新 normalize(避免 normalizeRule 重分配 fallback id 覆盖用户意图)
183
+ const tmpPath = `${configPath}.tmp`;
184
+ const content = `${JSON.stringify(config, null, 2)}\n`;
185
+
186
+ try {
187
+ mkdirSync(dirname(configPath), { recursive: true });
188
+ writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
189
+ renameSync(tmpPath, configPath);
190
+
191
+ // 更新缓存(用新文件的 mtime + size)
192
+ try {
193
+ const newStat = statSync(configPath);
194
+ configCache.set(configPath, { mtimeMs: newStat.mtimeMs, size: newStat.size, config: cloneConfig(config) });
195
+ } catch (statErr) {
196
+ // stat 失败不影响保存成功;缓存下次 load 时会重读。记录原因便于调试。
197
+ console.warn(`[pi-permission] saveConfig stat after write failed:`, statErr instanceof Error ? statErr.message : String(statErr));
198
+ }
199
+
200
+ return { success: true };
201
+ } catch (error) {
202
+ try {
203
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
204
+ } catch (cleanupErr) {
205
+ // tmp 清理失败不能阻塞保存失败的返回;记录原因
206
+ console.warn(`[pi-permission] saveConfig tmp cleanup failed:`, cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr));
207
+ }
208
+ const message = error instanceof Error ? error.message : String(error);
209
+ return { success: false, error: `Failed to save config at '${configPath}': ${message}` };
210
+ }
211
+ }
212
+
213
+ /** 测试/内部用:设置特定路径的缓存(绕过文件系统) */
214
+ export function setConfigCache(configPath: string, config: PermissionConfig, mtimeMs: number, size: number): void {
215
+ configCache.set(configPath, { mtimeMs, size, config });
216
+ }