@zhushanwen/pi-permission 1.6.1 → 1.6.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "Pi permission extension — four permission modes (yolo/auto/approve/strict) with three-layer pipeline (AST + rules + AI classifier).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,199 @@
1
+ /**
2
+ * 旧工具名精确规则迁移测试(tool-declaration-slimming D3 / U-B1 验收条款②)。
3
+ *
4
+ * 表驱动六用例(五分支 + 分支5b 反向混合集):
5
+ * 1. 精确旧名展开——subagent → start/manage/message、workflow → run/control,
6
+ * 展开顺序承重(D3 契约①),pattern/action/source/description 保留;
7
+ * 2. wildcard 不动——'*' 与其他含通配形态的 tool 一律原样;
8
+ * 3. 幂等——迁移结果再迁移深度相等(无旧名即零变化);
9
+ * 4. 展开后 id 全唯一——makeNextIdCounter 从既有 user-N 最大值 +1 递增,
10
+ * 不与既有规则冲突、展开条目互不冲突;
11
+ * 5. 混合 wildcard 场景保序——经真实 matcher(matchNonBashTool,last-match-wins)
12
+ * 断言「新名调用在迁移后规则集上的结论 = 迁移前旧名调用在原规则集上的结论;
13
+ * wildcard 对各调用的结论迁移前后不变」。旧名调用在迁移后不可达(旧工具名不再
14
+ * 注册,D3「旧名不保留别名」),不对其迁移后结论作断言;
15
+ * 5b. 反向混合集(wildcard deny 在前、精确 allow 在后)——与分支5互为镜像,
16
+ * 区分度弱,承重位置锚 = 精确条目仍在 wildcard 之前。
17
+ *
18
+ * 匹配器行为注释:matchNonBashTool 的 tool 字段经 wildcardToRegExp 全锚定匹配
19
+ * (精确名 = 精确匹配,'*' = 匹配全部);pattern 对 path 匹配(path 缺省时对
20
+ * toolName)。测试用 path 固定为 "/x" 让 pattern '*' 命中路径、隔离 tool 维度。
21
+ */
22
+ import { describe, expect, it } from "vitest";
23
+
24
+ import type { Rule } from "../types.js";
25
+ import { migrateLegacyToolRules } from "../rules/legacy-tool-names.js";
26
+ import { matchNonBashTool } from "../pipeline.js";
27
+
28
+ function userRule(overrides: Partial<Rule> & { id: string; tool: string }): Rule {
29
+ return {
30
+ pattern: "*",
31
+ action: "deny",
32
+ source: "user",
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ describe("旧工具名精确规则迁移(D3)— 表驱动六用例(五分支 + 分支5b 反向混合集)", () => {
38
+ it("分支1:精确旧名展开(subagent 三条 + workflow 两条,展开顺序承重,其余字段保留)", () => {
39
+ const rules: Rule[] = [
40
+ userRule({ id: "user-1", tool: "subagent", pattern: "*", action: "deny", description: "no subagent" }),
41
+ userRule({ id: "user-2", tool: "workflow", pattern: "*", action: "ask", description: "ask workflow" }),
42
+ ];
43
+
44
+ const migrated = migrateLegacyToolRules(rules);
45
+
46
+ expect(migrated).toHaveLength(5);
47
+ // 展开顺序(D3 契约①):subagent → start/manage/message;workflow → run/control
48
+ expect(migrated.map((r) => r.tool)).toEqual([
49
+ "subagent_start",
50
+ "subagent_manage",
51
+ "subagent_message",
52
+ "workflow_run",
53
+ "workflow_control",
54
+ ]);
55
+ // 同 pattern/action/source/description 保留(只换 tool 名与 id)
56
+ expect(migrated[0]).toMatchObject({ pattern: "*", action: "deny", source: "user", description: "no subagent" });
57
+ expect(migrated[3]).toMatchObject({ pattern: "*", action: "ask", source: "user", description: "ask workflow" });
58
+ // 原数组不被改写(纯函数)
59
+ expect(rules).toHaveLength(2);
60
+ expect(rules[0]?.tool).toBe("subagent");
61
+ });
62
+
63
+ it("分支2:wildcard 与其他工具规则不动;无旧名时零拷贝快速路径", () => {
64
+ const wildcardRule = userRule({ id: "user-7", tool: "*", action: "allow" });
65
+ const bashRule = userRule({ id: "user-8", tool: "bash", pattern: "git *" });
66
+ const readGlobRule = userRule({ id: "user-9", tool: "read*", pattern: "*" });
67
+ const rules = [wildcardRule, bashRule, readGlobRule];
68
+ const migrated = migrateLegacyToolRules(rules);
69
+
70
+ // 无旧名规则 → 返回原数组引用(零拷贝快速路径),条目零变化
71
+ expect(migrated).toBe(rules);
72
+ expect(migrated.map((r) => r.tool)).toEqual(["*", "bash", "read*"]);
73
+ });
74
+
75
+ it("分支3:幂等(已迁移配置再迁移深度相等)", () => {
76
+ const legacy: Rule[] = [
77
+ userRule({ id: "user-1", tool: "subagent", action: "deny" }),
78
+ userRule({ id: "user-2", tool: "workflow", action: "ask" }),
79
+ userRule({ id: "user-3", tool: "*", action: "allow" }),
80
+ ];
81
+ const once = migrateLegacyToolRules(legacy);
82
+ const twice = migrateLegacyToolRules(once);
83
+
84
+ expect(twice).toEqual(once);
85
+ expect(twice).toHaveLength(once.length);
86
+ });
87
+
88
+ it("分支4:展开后 id 全唯一(不与既有冲突、互不冲突、makeNextIdCounter 递增形态)", () => {
89
+ const rules: Rule[] = [
90
+ userRule({ id: "user-2", tool: "subagent", action: "deny" }),
91
+ userRule({ id: "user-5", tool: "read", action: "allow" }),
92
+ userRule({ id: "user-3", tool: "workflow", action: "ask" }),
93
+ ];
94
+ const migrated = migrateLegacyToolRules(rules);
95
+
96
+ const ids = migrated.map((r) => r.id);
97
+ expect(new Set(ids).size).toBe(ids.length);
98
+ // 未迁移规则 id 保持不变
99
+ expect(migrated.find((r) => r.tool === "read")?.id).toBe("user-5");
100
+ // 展开条目全部是新分配的 user-N(> 既有最大值 5)
101
+ const expandedIds = migrated.filter((r) => r.tool.startsWith("subagent_") || r.tool.startsWith("workflow_")).map((r) => r.id);
102
+ expect(expandedIds).toHaveLength(5);
103
+ for (const id of expandedIds) {
104
+ expect(id).toMatch(/^user-(\d+)$/);
105
+ expect(Number.parseInt(id.slice(5), 10)).toBeGreaterThan(5);
106
+ }
107
+ });
108
+
109
+ it("分支5:混合 wildcard 场景保序(真实 matcher:新名结论 = 迁移前旧名结论;wildcard 结论不变)", () => {
110
+ // 混合规则集 A:精确 deny 在前、wildcard allow 在后——last-match-wins 下
111
+ // 尾部 wildcard allow 覆盖前面的精确 deny(旧名调用结论 = allow)。
112
+ // 若迁移把展开规则错误地追加到数组尾部(D3 契约①禁止的实装),新名 deny 会
113
+ // 落到 wildcard allow 之后 → 结论翻成 deny ≠ 旧行为,本断言即红灯。
114
+ const mixed: Rule[] = [
115
+ userRule({ id: "user-1", tool: "subagent", pattern: "*", action: "deny" }),
116
+ userRule({ id: "user-2", tool: "workflow", pattern: "*", action: "deny" }),
117
+ userRule({ id: "user-3", tool: "*", pattern: "*", action: "allow" }),
118
+ userRule({ id: "user-4", tool: "read", pattern: "*", action: "allow" }),
119
+ ];
120
+
121
+ const migrated = migrateLegacyToolRules(mixed);
122
+
123
+ // 保序:精确规则与其展开条目的相对位置不变(wildcard 仍在展开条目之后)
124
+ const tools = migrated.map((r) => r.tool);
125
+ expect(tools).toEqual([
126
+ "subagent_start",
127
+ "subagent_manage",
128
+ "subagent_message",
129
+ "workflow_run",
130
+ "workflow_control",
131
+ "*",
132
+ "read",
133
+ ]);
134
+
135
+ // ① 迁移前旧名调用的结论
136
+ const before = {
137
+ subagent: matchNonBashTool("subagent", "/x", mixed).action,
138
+ workflow: matchNonBashTool("workflow", "/x", mixed).action,
139
+ };
140
+ // ② 迁移后新名调用的结论(拆分后旧名不可达,只有新名可调用)
141
+ const after = {
142
+ subagentStart: matchNonBashTool("subagent_start", "/x", migrated).action,
143
+ subagentManage: matchNonBashTool("subagent_manage", "/x", migrated).action,
144
+ subagentMessage: matchNonBashTool("subagent_message", "/x", migrated).action,
145
+ workflowRun: matchNonBashTool("workflow_run", "/x", migrated).action,
146
+ workflowControl: matchNonBashTool("workflow_control", "/x", migrated).action,
147
+ };
148
+
149
+ expect(after.subagentStart).toBe(before.subagent);
150
+ expect(after.subagentManage).toBe(before.subagent);
151
+ expect(after.subagentMessage).toBe(before.subagent);
152
+ expect(after.workflowRun).toBe(before.workflow);
153
+ expect(after.workflowControl).toBe(before.workflow);
154
+ // 显式值锚(场景 A 真实结论):尾部 wildcard allow 覆盖精确 deny
155
+ expect(before.subagent).toBe("allow");
156
+ expect(after.subagentStart).toBe("allow");
157
+
158
+ // ③ wildcard 与其他规则对各调用的结论迁移前后不变
159
+ expect(matchNonBashTool("read", "/x", migrated).action).toBe(matchNonBashTool("read", "/x", mixed).action);
160
+ expect(matchNonBashTool("bash", "/x", migrated).action).toBe(matchNonBashTool("bash", "/x", mixed).action);
161
+ });
162
+
163
+ it("分支5b:反向混合集(wildcard deny 在前、精确 allow 在后)保序——新名结论 = 旧名结论", () => {
164
+ // 与分支5互为镜像:旧名调用结论 = deny(尾部精确 allow 被…不——allow 在
165
+ // wildcard deny 之后,last-match-wins 下 allow 胜。若错误追加,新名 allow
166
+ // 落在最后 → 仍 allow,本场景区分度弱;承重面是「精确条目仍在 wildcard
167
+ // 之前」的位置锚 + 与分支5合并覆盖两个方向)。
168
+ const mixed: Rule[] = [
169
+ userRule({ id: "user-1", tool: "*", pattern: "*", action: "deny" }),
170
+ userRule({ id: "user-2", tool: "subagent", pattern: "*", action: "allow" }),
171
+ userRule({ id: "user-3", tool: "workflow", pattern: "*", action: "ask" }),
172
+ ];
173
+ const migrated = migrateLegacyToolRules(mixed);
174
+
175
+ // 位置锚:展开条目紧跟原规则原位(wildcard deny 保持在前)
176
+ expect(migrated.map((r) => r.tool)).toEqual([
177
+ "*",
178
+ "subagent_start",
179
+ "subagent_manage",
180
+ "subagent_message",
181
+ "workflow_run",
182
+ "workflow_control",
183
+ ]);
184
+
185
+ const beforeSubagent = matchNonBashTool("subagent", "/x", mixed).action;
186
+ const beforeWorkflow = matchNonBashTool("workflow", "/x", mixed).action;
187
+ expect(matchNonBashTool("subagent_start", "/x", migrated).action).toBe(beforeSubagent);
188
+ expect(matchNonBashTool("subagent_manage", "/x", migrated).action).toBe(beforeSubagent);
189
+ expect(matchNonBashTool("subagent_message", "/x", migrated).action).toBe(beforeSubagent);
190
+ expect(matchNonBashTool("workflow_run", "/x", migrated).action).toBe(beforeWorkflow);
191
+ expect(matchNonBashTool("workflow_control", "/x", migrated).action).toBe(beforeWorkflow);
192
+ // 显式值锚:尾部精确 allow/ask 胜出前部 wildcard deny
193
+ expect(beforeSubagent).toBe("allow");
194
+ expect(beforeWorkflow).toBe("ask");
195
+ // wildcard deny 对无关工具的结论迁移前后不变(bash 只被头部 wildcard deny 命中)
196
+ expect(matchNonBashTool("bash", "/x", migrated).action).toBe(matchNonBashTool("bash", "/x", mixed).action);
197
+ expect(matchNonBashTool("bash", "/x", mixed).action).toBe("deny");
198
+ });
199
+ });
package/src/config.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  type PermissionConfig,
37
37
  type Rule,
38
38
  } from "./types.js";
39
+ import { migrateLegacyToolRules } from "./rules/legacy-tool-names.js";
39
40
 
40
41
  /** llm-shared 泛型 config 的包名(决定文件名 permission-ext-config.json,llm-shared getConfigPath 追加 -ext-config.json 后缀)。 */
41
42
  const CONFIG_PKG = "permission";
@@ -139,11 +140,18 @@ function warnLegacyConfigIfExists(): void {
139
140
  /**
140
141
  * 加载配置:文件缺失时创建默认配置文件并返回默认值;
141
142
  * 坏 JSON / normalize 失败回落默认值(onWarning 回调)。mtime+size 缓存由 llm-shared 提供。
143
+ *
144
+ * 返回前应用旧工具名精确规则迁移(rules/legacy-tool-names.ts,tool-declaration-slimming
145
+ * D3:subagent/workflow 拆分改名后,持久化的旧名精确规则原地展开为新名规则——
146
+ * 原地替换保序 + makeNextIdCounter 新 id + 幂等;只内存展开,不回写落盘,加载路径纯读)。
147
+ * 所有配置消费方(pipeline / rule-editor / statusline / commands)都经本入口取 config,
148
+ * 迁移单点在此即全消费面生效。
142
149
  */
143
150
  export function loadAndWatchConfig(onWarning?: (msg: string) => void): PermissionConfig {
144
151
  const configPath = getConfigPath();
145
152
  ensureConfigFile(configPath, onWarning);
146
- return loadConfig(CONFIG_PKG, DEFAULT_CONFIG, normalizeConfig, onWarning);
153
+ const config = loadConfig(CONFIG_PKG, DEFAULT_CONFIG, normalizeConfig, onWarning);
154
+ return { ...config, userRules: migrateLegacyToolRules(config.userRules) };
147
155
  }
148
156
 
149
157
  // ──────────────────────── 保存(原子写) ────────────────────────
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 旧工具名精确规则一次性迁移(tool-declaration-slimming D3 / U-B1)。
3
+ *
4
+ * 背景:subagent-workflow 扩展的 `subagent`/`workflow` 工具已拆分为
5
+ * subagent_start/subagent_manage/subagent_message 与 workflow_run/workflow_control,
6
+ * 旧名不保留别名(探针 PB-fork)。用户权限规则按工具名匹配(`Rule.tool`)并持久化
7
+ * 落盘(config.ts saveConfig),旧名精确规则随改名失配——deny 静默退化成 ask/AI 分类。
8
+ *
9
+ * 迁移语义(D3 两条契约):
10
+ * ① 展开落位与保序——精确匹配旧名的用户规则**原地替换**为其新名展开(展开顺序
11
+ * subagent → start/manage/message、workflow → run/control),与未迁移规则的相对
12
+ * 次序保持不变。matcher 是 last-match-wins 按数组顺序遍历(rules/matcher.ts
13
+ * lastMatchWins),「收集展开规则追加到数组尾部」式实装会静默翻转混合规则集
14
+ * (精确规则与 wildcard 规则共存)的 deny/allow 胜负且无报错。
15
+ * ② 新 id——展开出的每条分配新 id(复用 rule-templates.ts 的 makeNextIdCounter,
16
+ * 从既有 user-N 最大值 +1 递增),禁止克隆原对象共享 id:Rule.id 语义为唯一 id
17
+ * (types.ts),rule-editor 按 id 键控删除(rule-editor.ts delete op),同 id 多条
18
+ * 会导致删除错乱。
19
+ *
20
+ * 范围与幂等:
21
+ * - 只迁移**精确等于**旧名的 tool 字段(`rule.tool === "subagent"` / `"workflow"`);
22
+ * wildcard(`'*'`)与其他含通配形态的规则一律不动(D3:wildcard 规则不动)。
23
+ * - 幂等:新名不在旧名集合中,已迁移的配置再次加载零变化。
24
+ * - 只在内存展开(loadAndWatchConfig 读时迁移),不回写落盘——加载路径保持纯读;
25
+ * 用户下次经 rule-editor 保存时自然持久化新名形态。
26
+ *
27
+ * 跨包契约登记:本表展开目标名与 subagent-workflow 的工具注册名互为跨包隐式契约
28
+ * (两包无 import 依赖,漂移零报错)——SW 工具再拆分/改名须同步本表(旧名进 key、
29
+ * 新名进展开目标),失配后果 = 旧名 deny 规则静默退化成 ask/AI 分类。登记条目见
30
+ * docs/extensions/extension-conventions.md「跨包隐式契约:permission 旧名展开表
31
+ * ↔ subagent-workflow 工具注册名」。
32
+ */
33
+
34
+ import type { Rule } from "../types.js";
35
+ import { makeNextIdCounter } from "../rule-templates.js";
36
+
37
+ /** 精确旧名 → 拆分新名展开表(顺序承重:D3 契约①的展开次序)。 */
38
+ const LEGACY_TOOL_NAME_EXPANSION: Readonly<Record<string, readonly string[]>> = {
39
+ subagent: ["subagent_start", "subagent_manage", "subagent_message"],
40
+ workflow: ["workflow_run", "workflow_control"],
41
+ };
42
+
43
+ /**
44
+ * 对规则数组做旧名精确规则迁移(纯函数,幂等,保序)。
45
+ *
46
+ * - 非迁移规则原样保留(同对象引用),相对次序不变;
47
+ * - 命中旧名的规则原地展开为 N 条新名规则(pattern/action/source/description 保留,
48
+ * tool 换新名、id 全新递增分配);
49
+ * - 无旧名规则时返回原数组引用(零拷贝快速路径)。
50
+ */
51
+ export function migrateLegacyToolRules(rules: Rule[]): Rule[] {
52
+ const hasLegacy = rules.some((rule) => LEGACY_TOOL_NAME_EXPANSION[rule.tool] !== undefined);
53
+ if (!hasLegacy) return rules;
54
+
55
+ // 新 id 分配基准 = 迁移前全部既有规则(含将被展开的旧名规则本身)——
56
+ // makeNextIdCounter 从 user-N 最大值 +1 递增,展开条目不与任何既有 id 冲突。
57
+ const nextId = makeNextIdCounter(rules);
58
+ const result: Rule[] = [];
59
+ for (const rule of rules) {
60
+ const expansion = LEGACY_TOOL_NAME_EXPANSION[rule.tool];
61
+ if (expansion === undefined) {
62
+ result.push(rule);
63
+ continue;
64
+ }
65
+ for (const tool of expansion) {
66
+ result.push({ ...rule, id: nextId(), tool });
67
+ }
68
+ }
69
+ return result;
70
+ }