@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.
- package/README.md +455 -0
- package/index.ts +2 -0
- package/package.json +52 -0
- package/src/__tests__/approval.test.ts +456 -0
- package/src/__tests__/commands.test.ts +487 -0
- package/src/__tests__/config.test.ts +229 -0
- package/src/__tests__/e2e-modes.test.ts +253 -0
- package/src/__tests__/extension.test.ts +170 -0
- package/src/__tests__/index-integration.test.ts +282 -0
- package/src/__tests__/model-picker.test.ts +371 -0
- package/src/__tests__/pipeline.test.ts +976 -0
- package/src/__tests__/production.test.ts +102 -0
- package/src/__tests__/rule-editor-component.test.ts +475 -0
- package/src/__tests__/rule-editor.test.ts +252 -0
- package/src/__tests__/rule-templates.test.ts +264 -0
- package/src/__tests__/statusline.test.ts +221 -0
- package/src/__tests__/types.test.ts +59 -0
- package/src/approval.ts +352 -0
- package/src/ast/__tests__/analyzer.test.ts +262 -0
- package/src/ast/__tests__/loader.test.ts +136 -0
- package/src/ast/analyzer.ts +326 -0
- package/src/ast/index.ts +7 -0
- package/src/ast/loader.ts +113 -0
- package/src/classifier/__tests__/classifier.test.ts +285 -0
- package/src/classifier/__tests__/json-parser.test.ts +118 -0
- package/src/classifier/__tests__/model-resolver.test.ts +173 -0
- package/src/classifier/__tests__/prompt.test.ts +59 -0
- package/src/classifier/classifier.ts +242 -0
- package/src/classifier/index.ts +28 -0
- package/src/classifier/json-parser.ts +127 -0
- package/src/classifier/model-resolver.ts +295 -0
- package/src/classifier/prompt.ts +52 -0
- package/src/commands.ts +234 -0
- package/src/config.ts +216 -0
- package/src/index.ts +291 -0
- package/src/model-picker.ts +398 -0
- package/src/pipeline.ts +541 -0
- package/src/production.ts +102 -0
- package/src/rule-editor-component.ts +883 -0
- package/src/rule-editor.ts +262 -0
- package/src/rule-templates.ts +332 -0
- package/src/rules/__tests__/builtins.test.ts +614 -0
- package/src/rules/__tests__/index.test.ts +26 -0
- package/src/rules/__tests__/matcher.test.ts +378 -0
- package/src/rules/__tests__/wildcard.test.ts +84 -0
- package/src/rules/builtins.ts +680 -0
- package/src/rules/index.ts +27 -0
- package/src/rules/matcher.ts +160 -0
- package/src/rules/wildcard.ts +43 -0
- package/src/statusline.ts +226 -0
- package/src/types.ts +249 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W8: Rule editor overlay — three-mode dispatch (TUI / RPC / headless).
|
|
3
|
+
*
|
|
4
|
+
* editRulesViaOverlay:主入口,按 ctx.mode 分发。
|
|
5
|
+
* editViaRpc:RPC 循环(list → action → fill → ...),deps 注入。
|
|
6
|
+
* G16:RPC listOptions 用 applyOps(initialRules, ops) 重建。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { RuleEditorComponent } from "./rule-editor-component.js";
|
|
10
|
+
import { ALL_TEMPLATES, applyOps, PRESET_COMMANDS, type RuleOp } from "./rule-templates.js";
|
|
11
|
+
import type { Rule } from "./types.js";
|
|
12
|
+
|
|
13
|
+
// ──────────────────────── 类型 ────────────────────────
|
|
14
|
+
|
|
15
|
+
/** RuleOp re-export(rule-templates.ts 定义)。 */
|
|
16
|
+
export type { RuleOp } from "./rule-templates.js";
|
|
17
|
+
|
|
18
|
+
/** 编辑结果:RuleOp[](有变更)或 undefined(cancel / headless 降级)。 */
|
|
19
|
+
export type RuleEditorResult = RuleOp[] | undefined;
|
|
20
|
+
|
|
21
|
+
/** editRulesViaOverlay 的 UI 上下文子集(从 Pi ExtensionContext 提取)。 */
|
|
22
|
+
export interface RuleEditorContext {
|
|
23
|
+
mode: "tui" | "rpc" | "json" | "print";
|
|
24
|
+
ui: {
|
|
25
|
+
notify(msg: string, type?: "info" | "warning" | "error"): void;
|
|
26
|
+
select(title: string, options: string[], opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
|
|
27
|
+
custom<T = void>(
|
|
28
|
+
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
29
|
+
options?: { overlay?: boolean },
|
|
30
|
+
): Promise<T>;
|
|
31
|
+
/** 可选文本输入(custom 模板 / Reject-with-Reason 用)。SDK 提供,mock 可能缺失。 */
|
|
32
|
+
input?(title: string, placeholder?: string, opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** editViaRpc 依赖注入(便于测试 mock)。 */
|
|
37
|
+
export interface RuleEditorRpcDeps {
|
|
38
|
+
select: (title: string, options: string[]) => Promise<string | undefined>;
|
|
39
|
+
input: (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ──────────────────────── editRulesViaOverlay(主入口) ────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 通过 overlay 编辑规则(TUI / RPC / headless 三模式分发)。
|
|
46
|
+
*
|
|
47
|
+
* @param ctx UI 上下文(mode + ui.*)
|
|
48
|
+
* @param initialRules 当前 userRules
|
|
49
|
+
* @param sessionIdCounter user-N id 生成器闭包
|
|
50
|
+
* @param rpcDeps 可选 RPC 依赖注入(测试 mock 用)
|
|
51
|
+
* @returns RuleOp[](有变更)或 undefined(cancel / headless)
|
|
52
|
+
*/
|
|
53
|
+
export async function editRulesViaOverlay(
|
|
54
|
+
ctx: RuleEditorContext,
|
|
55
|
+
initialRules: readonly Rule[],
|
|
56
|
+
sessionIdCounter: () => string,
|
|
57
|
+
rpcDeps?: RuleEditorRpcDeps,
|
|
58
|
+
): Promise<RuleEditorResult> {
|
|
59
|
+
switch (ctx.mode) {
|
|
60
|
+
case "tui":
|
|
61
|
+
return await editViaTui(ctx, initialRules, sessionIdCounter);
|
|
62
|
+
case "rpc":
|
|
63
|
+
return await editViaRpc(ctx, initialRules, sessionIdCounter, rpcDeps);
|
|
64
|
+
case "json":
|
|
65
|
+
case "print":
|
|
66
|
+
default:
|
|
67
|
+
ctx.ui.notify("[pi-permission] Rule editor not available in headless mode. Edit ~/.pi/agent/permission-config.json directly.", "warning");
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ──────────────────────── TUI 分支 ────────────────────────
|
|
73
|
+
|
|
74
|
+
async function editViaTui(
|
|
75
|
+
ctx: RuleEditorContext,
|
|
76
|
+
initialRules: readonly Rule[],
|
|
77
|
+
sessionIdCounter: () => string,
|
|
78
|
+
): Promise<RuleEditorResult> {
|
|
79
|
+
return await ctx.ui.custom<RuleOp[]>(
|
|
80
|
+
(_tui, _theme, _kb, done) => {
|
|
81
|
+
const comp = new RuleEditorComponent(
|
|
82
|
+
initialRules,
|
|
83
|
+
sessionIdCounter,
|
|
84
|
+
done,
|
|
85
|
+
);
|
|
86
|
+
return comp;
|
|
87
|
+
},
|
|
88
|
+
{ overlay: true },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ──────────────────────── RPC 分支(G16) ────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* RPC 循环编辑:list → action select → fill input → ... 直到 Done / cancel。
|
|
96
|
+
*
|
|
97
|
+
* G16:每次 op 后 listOptions 更新(反映前序操作)。
|
|
98
|
+
* deps 注入 select/input 便于测试 mock。
|
|
99
|
+
*/
|
|
100
|
+
export async function editViaRpc(
|
|
101
|
+
ctx: RuleEditorContext,
|
|
102
|
+
initialRules: readonly Rule[],
|
|
103
|
+
sessionIdCounter: () => string,
|
|
104
|
+
rpcDeps?: RuleEditorRpcDeps,
|
|
105
|
+
): Promise<RuleEditorResult> {
|
|
106
|
+
const select = rpcDeps?.select ?? ctx.ui.select;
|
|
107
|
+
// ctx.ui.input 可用 → 用真实文本输入(custom 模板需要自由文本);
|
|
108
|
+
// 不可用 → fallback 把 input 降级为单选项 select(占位符文本,仅保底)。
|
|
109
|
+
const input = rpcDeps?.input
|
|
110
|
+
?? (typeof ctx.ui.input === "function"
|
|
111
|
+
? (title: string, ph?: string) => ctx.ui.input!(title, ph)
|
|
112
|
+
: (title: string, ph?: string) => ctx.ui.select(title, [ph ?? ""]).then((v) => v === undefined ? undefined : v));
|
|
113
|
+
|
|
114
|
+
const ops: RuleOp[] = [];
|
|
115
|
+
|
|
116
|
+
for (;;) {
|
|
117
|
+
// 构建 listOptions(G16:用 applyOps 反映前序操作)
|
|
118
|
+
const currentRules = applyOps([...initialRules], ops);
|
|
119
|
+
const ruleLabels = currentRules.map((r) => `[${r.action}] ${r.pattern}`);
|
|
120
|
+
const listOptions = [...ruleLabels, "[+ Add rule]", "[Done]"];
|
|
121
|
+
|
|
122
|
+
const listChoice = await select("[pi-permission] Permission Rules", listOptions);
|
|
123
|
+
if (listChoice === undefined) {
|
|
124
|
+
// cancel
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (listChoice === "[Done]") {
|
|
129
|
+
return ops.length > 0 ? ops : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (listChoice === "[+ Add rule]") {
|
|
133
|
+
const op = await rpcAddFlow(select, input, sessionIdCounter);
|
|
134
|
+
if (op !== undefined) {
|
|
135
|
+
ops.push(op);
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 选择了一条现有规则 → edit 模式
|
|
141
|
+
const ruleIdx = ruleLabels.indexOf(listChoice);
|
|
142
|
+
if (ruleIdx >= 0 && ruleIdx < currentRules.length) {
|
|
143
|
+
const existingRule = currentRules[ruleIdx];
|
|
144
|
+
if (existingRule !== undefined) {
|
|
145
|
+
const editOp = await rpcEditFlow(select, input, existingRule);
|
|
146
|
+
if (editOp !== undefined) {
|
|
147
|
+
ops.push(editOp);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** RPC add flow:选模板 → fill。 */
|
|
155
|
+
async function rpcAddFlow(
|
|
156
|
+
select: (title: string, options: string[]) => Promise<string | undefined>,
|
|
157
|
+
input: (title: string, placeholder?: string) => Promise<string | undefined>,
|
|
158
|
+
sessionIdCounter: () => string,
|
|
159
|
+
): Promise<RuleOp | undefined> {
|
|
160
|
+
const templateLabels = ALL_TEMPLATES.map((t) => t.label);
|
|
161
|
+
const templateChoice = await select("[pi-permission] Select template", templateLabels);
|
|
162
|
+
if (templateChoice === undefined) return undefined;
|
|
163
|
+
|
|
164
|
+
const template = ALL_TEMPLATES.find((t) => t.label === templateChoice);
|
|
165
|
+
if (template === undefined) return undefined;
|
|
166
|
+
|
|
167
|
+
const selections: Record<string, string | undefined> = {};
|
|
168
|
+
|
|
169
|
+
if (template.id === "custom") {
|
|
170
|
+
// custom: pattern + action + tool + description
|
|
171
|
+
const pattern = await input("[pi-permission] Pattern (wildcard, e.g. 'npm *')", "npm *");
|
|
172
|
+
if (pattern === undefined) return undefined;
|
|
173
|
+
selections.pattern = pattern;
|
|
174
|
+
|
|
175
|
+
const actionChoice = await select("[pi-permission] Action", ["allow", "deny", "ask"]);
|
|
176
|
+
if (actionChoice === undefined) return undefined;
|
|
177
|
+
selections.action = actionChoice;
|
|
178
|
+
|
|
179
|
+
selections.tool = "bash"; // G9
|
|
180
|
+
const desc = await input("[pi-permission] Description (optional)", "");
|
|
181
|
+
selections.description = desc || undefined;
|
|
182
|
+
} else {
|
|
183
|
+
// 非 custom:选命令(带搜索)
|
|
184
|
+
const cmdResult = await rpcSelectCommand(select, input);
|
|
185
|
+
if (cmdResult === undefined) return undefined;
|
|
186
|
+
selections.cmd = cmdResult;
|
|
187
|
+
|
|
188
|
+
// 选择范围:整个命令家族 / 特定子命令
|
|
189
|
+
if (template.id !== "allow-family" && template.id !== "ask-before") {
|
|
190
|
+
// deny-family / allow-subcmd:需要选子命令
|
|
191
|
+
const scopeChoice = await select(`[pi-permission] Select scope for ${cmdResult}`, [
|
|
192
|
+
`${cmdResult} * (all subcommands)`,
|
|
193
|
+
`${cmdResult} <subcommand> * (specific)`,
|
|
194
|
+
]);
|
|
195
|
+
if (scopeChoice === undefined) return undefined;
|
|
196
|
+
|
|
197
|
+
if (scopeChoice.includes("<subcommand>")) {
|
|
198
|
+
const subcmd = await input("[pi-permission] Enter subcommand name", "");
|
|
199
|
+
if (subcmd === undefined || subcmd.trim().length === 0) return undefined;
|
|
200
|
+
selections.subcmd = subcmd.trim();
|
|
201
|
+
} else {
|
|
202
|
+
selections.subcmd = "__any__";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const built = template.build(selections);
|
|
208
|
+
const ruleId = sessionIdCounter(); // G13:立即赋值
|
|
209
|
+
const rule: Rule = { id: ruleId, ...built };
|
|
210
|
+
return { kind: "add", rule };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** RPC 命令选择(带搜索) */
|
|
214
|
+
async function rpcSelectCommand(
|
|
215
|
+
select: (title: string, options: string[]) => Promise<string | undefined>,
|
|
216
|
+
input: (title: string, placeholder?: string) => Promise<string | undefined>,
|
|
217
|
+
): Promise<string | undefined> {
|
|
218
|
+
// 先问用户是否要搜索
|
|
219
|
+
const modeChoice = await select("[pi-permission] How to select command?", [
|
|
220
|
+
"[Browse list]",
|
|
221
|
+
"[Type to search]",
|
|
222
|
+
]);
|
|
223
|
+
if (modeChoice === undefined) return undefined;
|
|
224
|
+
|
|
225
|
+
if (modeChoice === "[Type to search]") {
|
|
226
|
+
// 直接输入命令名
|
|
227
|
+
const cmd = await input("[pi-permission] Enter command name", "");
|
|
228
|
+
if (cmd === undefined || cmd.trim().length === 0) return undefined;
|
|
229
|
+
return cmd.trim();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 浏览列表
|
|
233
|
+
const cmdOptions = [...PRESET_COMMANDS.map((c) => c.label), "[Other]"];
|
|
234
|
+
const cmdChoice = await select("[pi-permission] Select command", cmdOptions);
|
|
235
|
+
if (cmdChoice === undefined) return undefined;
|
|
236
|
+
|
|
237
|
+
if (cmdChoice === "[Other]") {
|
|
238
|
+
const cmd = await input("[pi-permission] Enter command name", "");
|
|
239
|
+
if (cmd === undefined || cmd.trim().length === 0) return undefined;
|
|
240
|
+
return cmd.trim();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const preset = PRESET_COMMANDS.find((c) => c.label === cmdChoice);
|
|
244
|
+
return preset?.cmd ?? cmdChoice;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** RPC edit flow:选择 action(edit fields / delete)。 */
|
|
248
|
+
async function rpcEditFlow(
|
|
249
|
+
select: (title: string, options: string[]) => Promise<string | undefined>,
|
|
250
|
+
_input: (title: string, placeholder?: string) => Promise<string | undefined>,
|
|
251
|
+
existingRule: Rule,
|
|
252
|
+
): Promise<RuleOp | undefined> {
|
|
253
|
+
const actionChoice = await select(`[pi-permission] Edit rule: ${existingRule.pattern}`, [
|
|
254
|
+
"[Delete this rule]",
|
|
255
|
+
"[Cancel]",
|
|
256
|
+
]);
|
|
257
|
+
if (actionChoice === undefined || actionChoice === "[Cancel]") return undefined;
|
|
258
|
+
if (actionChoice === "[Delete this rule]") {
|
|
259
|
+
return { kind: "delete", id: existingRule.id };
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W8: Rule templates + preset commands + applyOps + classifyRuleTemplate.
|
|
3
|
+
*
|
|
4
|
+
* G4:pattern 用 wildcard(`npm *`),不用正则(`\bnpm\b`)。
|
|
5
|
+
* W3 matcher 对 user 规则走 `wildcardToRegExp(pattern)`,末尾 ` *` 匹配无参 + 有参。
|
|
6
|
+
*
|
|
7
|
+
* G8:build 不生成 id(由 commitFill 赋 sessionIdCounter())。
|
|
8
|
+
* G13:sessionIdCounter 立即赋值,不用 `__new__` 占位。
|
|
9
|
+
* applyOps 无 nextId 参数,add 分支直接用 op.rule.id。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PermissionAction, Rule, RuleSource } from "./types.js";
|
|
13
|
+
|
|
14
|
+
// ──────────────────────── PRESET_COMMANDS(53 命令 + Other) ────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface PresetCommand {
|
|
17
|
+
cmd: string;
|
|
18
|
+
label: string;
|
|
19
|
+
category: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 包管理器 6 + 容器云 5 + VCS 3 + 构建任务 6 + 网络下载 8 + 文件系统 9 + 运行时 2 + 文本处理 6 + 系统信息 8 = 53 */
|
|
23
|
+
export const PRESET_COMMANDS: readonly PresetCommand[] = [
|
|
24
|
+
// 包管理器
|
|
25
|
+
{ cmd: "npm", label: "npm (Node package manager)", category: "Package Managers" },
|
|
26
|
+
{ cmd: "yarn", label: "yarn (Node package manager)", category: "Package Managers" },
|
|
27
|
+
{ cmd: "pnpm", label: "pnpm (Node package manager)", category: "Package Managers" },
|
|
28
|
+
{ cmd: "pip", label: "pip (Python package installer)", category: "Package Managers" },
|
|
29
|
+
{ cmd: "brew", label: "brew (macOS package manager)", category: "Package Managers" },
|
|
30
|
+
{ cmd: "apt", label: "apt (Debian/Ubuntu package manager)", category: "Package Managers" },
|
|
31
|
+
// 容器云
|
|
32
|
+
{ cmd: "docker", label: "docker (container runtime)", category: "Containers & Cloud" },
|
|
33
|
+
{ cmd: "kubectl", label: "kubectl (Kubernetes CLI)", category: "Containers & Cloud" },
|
|
34
|
+
{ cmd: "terraform", label: "terraform (infrastructure as code)", category: "Containers & Cloud" },
|
|
35
|
+
{ cmd: "aws", label: "aws (AWS CLI)", category: "Containers & Cloud" },
|
|
36
|
+
{ cmd: "gcloud", label: "gcloud (Google Cloud CLI)", category: "Containers & Cloud" },
|
|
37
|
+
// VCS
|
|
38
|
+
{ cmd: "git", label: "git (version control)", category: "Version Control" },
|
|
39
|
+
{ cmd: "gh", label: "gh (GitHub CLI)", category: "Version Control" },
|
|
40
|
+
{ cmd: "hg", label: "hg (Mercurial)", category: "Version Control" },
|
|
41
|
+
// 构建任务
|
|
42
|
+
{ cmd: "make", label: "make (build tool)", category: "Build & Tasks" },
|
|
43
|
+
{ cmd: "cargo", label: "cargo (Rust build tool)", category: "Build & Tasks" },
|
|
44
|
+
{ cmd: "go", label: "go (Go toolchain)", category: "Build & Tasks" },
|
|
45
|
+
{ cmd: "mvn", label: "mvn (Maven)", category: "Build & Tasks" },
|
|
46
|
+
{ cmd: "gradle", label: "gradle (Gradle build)", category: "Build & Tasks" },
|
|
47
|
+
{ cmd: "cmake", label: "cmake (C/C++ build)", category: "Build & Tasks" },
|
|
48
|
+
// 网络下载
|
|
49
|
+
{ cmd: "curl", label: "curl (HTTP client)", category: "Network & Download" },
|
|
50
|
+
{ cmd: "wget", label: "wget (downloader)", category: "Network & Download" },
|
|
51
|
+
{ cmd: "ssh", label: "ssh (remote shell)", category: "Network & Download" },
|
|
52
|
+
{ cmd: "scp", label: "scp (secure copy)", category: "Network & Download" },
|
|
53
|
+
{ cmd: "rsync", label: "rsync (file sync)", category: "Network & Download" },
|
|
54
|
+
{ cmd: "ping", label: "ping (network probe)", category: "Network & Download" },
|
|
55
|
+
{ cmd: "dig", label: "dig (DNS lookup)", category: "Network & Download" },
|
|
56
|
+
{ cmd: "ssh-keygen", label: "ssh-keygen (SSH key generation)", category: "Network & Download" },
|
|
57
|
+
// 文件系统
|
|
58
|
+
{ cmd: "rm", label: "rm (remove files)", category: "Filesystem" },
|
|
59
|
+
{ cmd: "cp", label: "cp (copy files)", category: "Filesystem" },
|
|
60
|
+
{ cmd: "mv", label: "mv (move files)", category: "Filesystem" },
|
|
61
|
+
{ cmd: "chmod", label: "chmod (change permissions)", category: "Filesystem" },
|
|
62
|
+
{ cmd: "ls", label: "ls (list directory)", category: "Filesystem" },
|
|
63
|
+
{ cmd: "cat", label: "cat (concatenate files)", category: "Filesystem" },
|
|
64
|
+
{ cmd: "find", label: "find (search files)", category: "Filesystem" },
|
|
65
|
+
{ cmd: "grep", label: "grep (search text)", category: "Filesystem" },
|
|
66
|
+
{ cmd: "tree", label: "tree (directory tree)", category: "Filesystem" },
|
|
67
|
+
// 运行时
|
|
68
|
+
{ cmd: "node", label: "node (Node.js runtime)", category: "Runtime" },
|
|
69
|
+
{ cmd: "python", label: "python (Python runtime)", category: "Runtime" },
|
|
70
|
+
// 文本处理
|
|
71
|
+
{ cmd: "jq", label: "jq (JSON processor)", category: "Text Processing" },
|
|
72
|
+
{ cmd: "sed", label: "sed (stream editor)", category: "Text Processing" },
|
|
73
|
+
{ cmd: "awk", label: "awk (text processing)", category: "Text Processing" },
|
|
74
|
+
{ cmd: "sort", label: "sort (sort lines)", category: "Text Processing" },
|
|
75
|
+
{ cmd: "diff", label: "diff (compare files)", category: "Text Processing" },
|
|
76
|
+
{ cmd: "iconv", label: "iconv (encoding converter)", category: "Text Processing" },
|
|
77
|
+
// 系统信息
|
|
78
|
+
{ cmd: "ps", label: "ps (process list)", category: "System & Info" },
|
|
79
|
+
{ cmd: "top", label: "top (process monitor)", category: "System & Info" },
|
|
80
|
+
{ cmd: "du", label: "du (disk usage)", category: "System & Info" },
|
|
81
|
+
{ cmd: "df", label: "df (disk free)", category: "System & Info" },
|
|
82
|
+
{ cmd: "uname", label: "uname (system info)", category: "System & Info" },
|
|
83
|
+
{ cmd: "whoami", label: "whoami (current user)", category: "System & Info" },
|
|
84
|
+
{ cmd: "env", label: "env (environment variables)", category: "System & Info" },
|
|
85
|
+
{ cmd: "date", label: "date (system date)", category: "System & Info" },
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
/** PRESET_COMMANDS 按 category 分组(UI 展示用)。 */
|
|
89
|
+
export function presetCommandsByCategory(): Map<string, PresetCommand[]> {
|
|
90
|
+
const map = new Map<string, PresetCommand[]>();
|
|
91
|
+
for (const cmd of PRESET_COMMANDS) {
|
|
92
|
+
const list = map.get(cmd.category);
|
|
93
|
+
if (list) {
|
|
94
|
+
list.push(cmd);
|
|
95
|
+
} else {
|
|
96
|
+
map.set(cmd.category, [cmd]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return map;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ──────────────────────── RuleTemplate ────────────────────────
|
|
103
|
+
|
|
104
|
+
/** 模板 selections 值类型(各字段按模板分支含义不同)。 */
|
|
105
|
+
export interface TemplateSelections {
|
|
106
|
+
cmd?: string;
|
|
107
|
+
subcmd?: string;
|
|
108
|
+
/** Custom 模板直接传完整字段 */
|
|
109
|
+
pattern?: string;
|
|
110
|
+
action?: PermissionAction;
|
|
111
|
+
tool?: string;
|
|
112
|
+
description?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** build 返回的 Rule(无 id,由 commitFill 赋值)。 */
|
|
116
|
+
export interface RuleWithoutId {
|
|
117
|
+
tool: string;
|
|
118
|
+
pattern: string;
|
|
119
|
+
action: PermissionAction;
|
|
120
|
+
source: RuleSource;
|
|
121
|
+
description?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface RuleTemplate {
|
|
125
|
+
id: string;
|
|
126
|
+
label: string;
|
|
127
|
+
description: string;
|
|
128
|
+
build(selections: TemplateSelections): RuleWithoutId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ──────────────────────── 5 个模板 ────────────────────────
|
|
132
|
+
|
|
133
|
+
export const allowFamilyTemplate: RuleTemplate = {
|
|
134
|
+
id: "allow-family",
|
|
135
|
+
label: "Allow command family",
|
|
136
|
+
description: "Allow all invocations of a command (e.g., npm install, npm run build, npm with no args)",
|
|
137
|
+
build(selections): RuleWithoutId {
|
|
138
|
+
const cmd = selections.cmd ?? "unknown";
|
|
139
|
+
return {
|
|
140
|
+
tool: "bash",
|
|
141
|
+
pattern: `${cmd} *`,
|
|
142
|
+
action: "allow",
|
|
143
|
+
source: "user",
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const denyFamilyTemplate: RuleTemplate = {
|
|
149
|
+
id: "deny-family",
|
|
150
|
+
label: "Deny command family",
|
|
151
|
+
description: "Deny all invocations of a command (or specific subcommand)",
|
|
152
|
+
build(selections): RuleWithoutId {
|
|
153
|
+
const cmd = selections.cmd ?? "unknown";
|
|
154
|
+
const subcmd = selections.subcmd;
|
|
155
|
+
if (subcmd === undefined || subcmd === "__any__") {
|
|
156
|
+
return {
|
|
157
|
+
tool: "bash",
|
|
158
|
+
pattern: `${cmd} *`,
|
|
159
|
+
action: "deny",
|
|
160
|
+
source: "user",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
tool: "bash",
|
|
165
|
+
pattern: `${cmd} ${subcmd} *`,
|
|
166
|
+
action: "deny",
|
|
167
|
+
source: "user",
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const askBeforeTemplate: RuleTemplate = {
|
|
173
|
+
id: "ask-before",
|
|
174
|
+
label: "Ask before command",
|
|
175
|
+
description: "Ask for confirmation before running a command",
|
|
176
|
+
build(selections): RuleWithoutId {
|
|
177
|
+
const cmd = selections.cmd ?? "unknown";
|
|
178
|
+
return {
|
|
179
|
+
tool: "bash",
|
|
180
|
+
pattern: `${cmd} *`,
|
|
181
|
+
action: "ask",
|
|
182
|
+
source: "user",
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const allowSubcmdTemplate: RuleTemplate = {
|
|
188
|
+
id: "allow-subcmd",
|
|
189
|
+
label: "Allow specific subcommand",
|
|
190
|
+
description: "Allow only a specific subcommand (e.g., git status, git log)",
|
|
191
|
+
build(selections): RuleWithoutId {
|
|
192
|
+
const cmd = selections.cmd ?? "unknown";
|
|
193
|
+
const subcmd = selections.subcmd;
|
|
194
|
+
// __any__ 哨兵:用户在 RPC 流程选了「all subcommands」→ 退化为 allow-family 语义,
|
|
195
|
+
// 避免拼成永不匹配的死规则 `<cmd> __any__ *`(与 deny-family 对称)。
|
|
196
|
+
if (subcmd === undefined || subcmd === "__any__") {
|
|
197
|
+
return {
|
|
198
|
+
tool: "bash",
|
|
199
|
+
pattern: `${cmd} *`,
|
|
200
|
+
action: "allow",
|
|
201
|
+
source: "user",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
tool: "bash",
|
|
206
|
+
pattern: `${cmd} ${subcmd} *`,
|
|
207
|
+
action: "allow",
|
|
208
|
+
source: "user",
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const customTemplate: RuleTemplate = {
|
|
214
|
+
id: "custom",
|
|
215
|
+
label: "Custom (advanced)",
|
|
216
|
+
description: "Define a custom rule with arbitrary pattern, action, and tool",
|
|
217
|
+
build(selections): RuleWithoutId {
|
|
218
|
+
return {
|
|
219
|
+
tool: selections.tool ?? "bash",
|
|
220
|
+
pattern: selections.pattern ?? "*",
|
|
221
|
+
action: selections.action ?? "allow",
|
|
222
|
+
source: "user",
|
|
223
|
+
description: selections.description,
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** 所有模板(5 个,顺序固定)。 */
|
|
229
|
+
export const ALL_TEMPLATES: readonly RuleTemplate[] = [
|
|
230
|
+
allowFamilyTemplate,
|
|
231
|
+
denyFamilyTemplate,
|
|
232
|
+
askBeforeTemplate,
|
|
233
|
+
allowSubcmdTemplate,
|
|
234
|
+
customTemplate,
|
|
235
|
+
] as const;
|
|
236
|
+
|
|
237
|
+
// ──────────────────────── classifyRuleTemplate ────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* FAMILY_RE:`<cmd> *` 形式(allow-family / deny-family any / ask-before)。
|
|
241
|
+
* SUBCMD_RE:`<cmd> <subcmd> *` 形式(deny-family specific / allow-subcmd)。
|
|
242
|
+
*/
|
|
243
|
+
const FAMILY_RE = /^([^ *?]+) \*$/;
|
|
244
|
+
const SUBCMD_RE = /^([^ *?]+) ([^ *?]+) \*$/;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 根据 rule 的 pattern + action 推断其模板 id。
|
|
248
|
+
* 无法推断时返回 undefined(如 Custom 规则)。
|
|
249
|
+
*/
|
|
250
|
+
export function classifyRuleTemplate(rule: Rule): string | undefined {
|
|
251
|
+
const { pattern, action } = rule;
|
|
252
|
+
|
|
253
|
+
const subMatch = SUBCMD_RE.exec(pattern);
|
|
254
|
+
if (subMatch !== null) {
|
|
255
|
+
// `<cmd> <subcmd> *`
|
|
256
|
+
if (action === "deny") return "deny-family";
|
|
257
|
+
if (action === "allow") return "allow-subcmd";
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const famMatch = FAMILY_RE.exec(pattern);
|
|
262
|
+
if (famMatch !== null) {
|
|
263
|
+
// `<cmd> *`
|
|
264
|
+
if (action === "allow") return "allow-family";
|
|
265
|
+
if (action === "deny") return "deny-family";
|
|
266
|
+
if (action === "ask") return "ask-before";
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 不匹配任何已知 pattern 格式 → custom
|
|
271
|
+
return "custom";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ──────────────────────── applyOps ────────────────────────
|
|
275
|
+
|
|
276
|
+
/** RuleOp discriminated union(G13:add 时 rule.id 已是真实 user-N)。 */
|
|
277
|
+
export type RuleOp =
|
|
278
|
+
| { kind: "add"; rule: Rule }
|
|
279
|
+
| { kind: "edit"; id: string; rule: Rule }
|
|
280
|
+
| { kind: "delete"; id: string };
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 纯函数:对 userRules 施加 ops,返回新数组。
|
|
284
|
+
* - add:push op.rule(id 已赋值)
|
|
285
|
+
* - edit:findIndex(id) + 替换
|
|
286
|
+
* - delete:filter 过滤
|
|
287
|
+
*
|
|
288
|
+
* G8/G13:无 nextId 参数(add 时 rule.id 由 sessionIdCounter 立即赋值)。
|
|
289
|
+
*/
|
|
290
|
+
export function applyOps(userRules: Rule[], ops: RuleOp[]): Rule[] {
|
|
291
|
+
let result = [...userRules];
|
|
292
|
+
for (const op of ops) {
|
|
293
|
+
switch (op.kind) {
|
|
294
|
+
case "add": {
|
|
295
|
+
result = [...result, op.rule];
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "edit": {
|
|
299
|
+
const idx = result.findIndex((r) => r.id === op.id);
|
|
300
|
+
if (idx >= 0) {
|
|
301
|
+
result = [...result.slice(0, idx), op.rule, ...result.slice(idx + 1)];
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "delete": {
|
|
306
|
+
result = result.filter((r) => r.id !== op.id);
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ──────────────────────── makeNextIdCounter ────────────────────────
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 构造 sessionIdCounter 闭包:从已有规则中提取 user-N 最大值 +1 递增。
|
|
318
|
+
*
|
|
319
|
+
* G13:commitFill 时 `rule.id = this.sessionIdCounter()` 立即拿到真实 id。
|
|
320
|
+
*/
|
|
321
|
+
export function makeNextIdCounter(existingRules: Rule[]): () => string {
|
|
322
|
+
let maxN = 0;
|
|
323
|
+
for (const rule of existingRules) {
|
|
324
|
+
const m = /^user-(\d+)$/.exec(rule.id);
|
|
325
|
+
if (m !== null) {
|
|
326
|
+
const n = Number.parseInt(m[1], 10);
|
|
327
|
+
if (n > maxN) maxN = n;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
let next = maxN + 1;
|
|
331
|
+
return (): string => `user-${next++}`;
|
|
332
|
+
}
|