@zhushanwen/pi-permission 1.4.2 → 1.4.4
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 +4 -4
- package/src/__tests__/approval.test.ts +48 -1
- package/src/__tests__/commands.test.ts +59 -68
- package/src/__tests__/model-picker.test.ts +46 -22
- package/src/__tests__/pipeline.test.ts +24 -0
- package/src/approval.ts +68 -31
- package/src/ast/analyzer.ts +2 -1
- package/src/ast/loader.ts +2 -1
- package/src/classifier/classifier.ts +3 -2
- package/src/classifier/index.ts +4 -16
- package/src/classifier/model-resolver.ts +3 -16
- package/src/commands.ts +15 -34
- package/src/config.ts +7 -24
- package/src/index.ts +48 -67
- package/src/model-picker.ts +19 -38
- package/src/pipeline.ts +27 -44
- package/src/rule-editor.ts +8 -22
- package/src/rules/__tests__/index.test.ts +5 -14
- package/src/rules/__tests__/matcher.test.ts +16 -23
- package/src/rules/index.ts +7 -24
- package/src/rules/matcher.ts +29 -22
package/src/model-picker.ts
CHANGED
|
@@ -21,13 +21,11 @@
|
|
|
21
21
|
|
|
22
22
|
import { type Component, Container, type SelectItem, SelectList, type SelectListTheme, truncateToWidth } from "@earendil-works/pi-tui";
|
|
23
23
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
24
|
+
import { parseModelRef } from "@zhushanwen/pi-llm-shared";
|
|
24
25
|
|
|
25
26
|
import type { ResolvedModelEntry } from "./classifier/model-resolver.js";
|
|
26
27
|
import { DEFAULT_SELECT_THEME } from "./select-theme.js";
|
|
27
28
|
|
|
28
|
-
// 保持既有 public 名(model-picker.test.ts MPT7 直接消费此路径)。
|
|
29
|
-
export { DEFAULT_SELECT_THEME };
|
|
30
|
-
|
|
31
29
|
// ──────────────────────── 类型 ────────────────────────
|
|
32
30
|
|
|
33
31
|
/** 选择结果(discriminated union);undefined = cancel / 降级。 */
|
|
@@ -134,11 +132,6 @@ export class ProviderModelSelectorComponent extends Container {
|
|
|
134
132
|
return lines;
|
|
135
133
|
}
|
|
136
134
|
|
|
137
|
-
/** 退出(外部 abort 用)。复用 _resolved 守卫防二次 done。 */
|
|
138
|
-
cancel(): void {
|
|
139
|
-
this.settle(undefined);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
135
|
private settle(result: SelectionResult | undefined): void {
|
|
143
136
|
if (this._resolved) return;
|
|
144
137
|
this._resolved = true;
|
|
@@ -190,13 +183,16 @@ export class ProviderModelSelectorComponent extends Container {
|
|
|
190
183
|
return items;
|
|
191
184
|
}
|
|
192
185
|
|
|
193
|
-
/**
|
|
186
|
+
/**
|
|
187
|
+
* 计算 provider stage 预选 index(currentSpec='auto' → 0;'provider/model' → findIndex)。
|
|
188
|
+
* 无效 ref(缺 / 或前后为空,如 'provider/')→ parseModelRef 返 null → 0(整体回 Auto,
|
|
189
|
+
* ext-simplify-18 D4 登记的行为微变:不再预选半截 provider)。
|
|
190
|
+
*/
|
|
194
191
|
private computeProviderSelectedIndex(): number {
|
|
195
192
|
if (this.currentSpec === "auto") return 0;
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
const
|
|
199
|
-
const idx = this.providers.indexOf(provider);
|
|
193
|
+
const parsed = parseModelRef(this.currentSpec);
|
|
194
|
+
if (!parsed) return 0;
|
|
195
|
+
const idx = this.providers.indexOf(parsed.provider);
|
|
200
196
|
return idx >= 0 ? idx + 1 : 0; // +1 跳过 'Auto'
|
|
201
197
|
}
|
|
202
198
|
|
|
@@ -235,12 +231,11 @@ export class ProviderModelSelectorComponent extends Container {
|
|
|
235
231
|
return list;
|
|
236
232
|
}
|
|
237
233
|
|
|
238
|
-
/** 计算 model stage 预选 index
|
|
234
|
+
/** 计算 model stage 预选 index(无效 ref → parseModelRef 返 null → 0)。 */
|
|
239
235
|
private computeModelSelectedIndex(models: readonly ResolvedModelEntry[]): number {
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
242
|
-
const
|
|
243
|
-
const idx = models.findIndex((m) => m.id === modelId);
|
|
236
|
+
const parsed = parseModelRef(this.currentSpec);
|
|
237
|
+
if (!parsed) return 0;
|
|
238
|
+
const idx = models.findIndex((m) => m.id === parsed.modelId);
|
|
244
239
|
return idx >= 0 ? idx : 0;
|
|
245
240
|
}
|
|
246
241
|
|
|
@@ -282,34 +277,28 @@ export interface ModelPickerContext {
|
|
|
282
277
|
};
|
|
283
278
|
}
|
|
284
279
|
|
|
285
|
-
/** listAvailableModels 注入签名(E2 改走 ctx.modelRegistry,签名带 ctx;便于测试 mock)。 */
|
|
286
|
-
export type ListAvailableModelsFn = (
|
|
287
|
-
ctx: ModelPickerContext,
|
|
288
|
-
) => Map<string, ResolvedModelEntry[]>;
|
|
289
|
-
|
|
290
280
|
/**
|
|
291
281
|
* 通过 overlay 选择模型(provider/model 或 auto)。
|
|
292
282
|
*
|
|
293
283
|
* @param ctx UI 上下文(mode + ui.*)
|
|
294
284
|
* @param currentSpec 当前 classifier.model('auto' 或 'provider/model-id'),用于预选
|
|
295
|
-
* @param models
|
|
285
|
+
* @param models 可用模型 Map(listAvailableModels 产物,按 provider 分组;调用方判空降级)
|
|
296
286
|
* @returns 'auto' / 'provider/model-id' / undefined(cancel 或 headless 降级)
|
|
297
287
|
*/
|
|
298
288
|
export async function pickModelViaOverlay(
|
|
299
289
|
ctx: ModelPickerContext,
|
|
300
290
|
currentSpec: string,
|
|
301
|
-
models
|
|
291
|
+
models: Map<string, ResolvedModelEntry[]>,
|
|
302
292
|
): Promise<string | undefined> {
|
|
303
|
-
|
|
304
|
-
if (modelsByProvider.size === 0) return undefined;
|
|
293
|
+
if (models.size === 0) return undefined;
|
|
305
294
|
|
|
306
|
-
const providers = [...
|
|
295
|
+
const providers = [...models.keys()];
|
|
307
296
|
|
|
308
297
|
switch (ctx.mode) {
|
|
309
298
|
case "tui":
|
|
310
|
-
return await pickViaTui(ctx, currentSpec, providers,
|
|
299
|
+
return await pickViaTui(ctx, currentSpec, providers, models);
|
|
311
300
|
case "rpc":
|
|
312
|
-
return await pickViaRpc(ctx, currentSpec, providers,
|
|
301
|
+
return await pickViaRpc(ctx, currentSpec, providers, models);
|
|
313
302
|
case "json":
|
|
314
303
|
case "print":
|
|
315
304
|
default:
|
|
@@ -317,14 +306,6 @@ export async function pickModelViaOverlay(
|
|
|
317
306
|
}
|
|
318
307
|
}
|
|
319
308
|
|
|
320
|
-
/** 默认 listAvailableModels(生产路径用,index.ts 装配时注入真实实现)。 */
|
|
321
|
-
let listAvailableModelsDefault: ListAvailableModelsFn = (): Map<string, ResolvedModelEntry[]> => new Map();
|
|
322
|
-
|
|
323
|
-
/** 注入默认 listAvailableModels 实现(index.ts 装配时调用,或测试覆盖)。 */
|
|
324
|
-
export function setDefaultListAvailableModels(fn: ListAvailableModelsFn): void {
|
|
325
|
-
listAvailableModelsDefault = fn;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
309
|
// ──────────────────────── TUI 分支 ────────────────────────
|
|
329
310
|
|
|
330
311
|
/**
|
package/src/pipeline.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 架构(自上而下):
|
|
5
5
|
* - checkPermission:纯函数,按 mode 分发。deps 全注入(测试可 mock,不触碰 fs/network)。
|
|
6
6
|
* - runLayer2:bash 走 matchRulesForArgv(遍历 AST 拆出的 argv[]);非 bash 走
|
|
7
|
-
* matchNonBashTool(G1
|
|
8
|
-
*
|
|
7
|
+
* matchNonBashTool(G1:让 Read/Write/Edit 也评估用户规则;pattern 双语义与
|
|
8
|
+
* last-match-wins 循环均经 matcher 单源,wildcardToRegExp 只编译 tool 字段)。
|
|
9
9
|
* - runLayer3WithRacing:AI Classifier + 用户审批竞速(移植 classifier-racing.ts)。
|
|
10
10
|
* G3:用户审批的 ctx.ui.custom 工厂在调之前先检查 signal.aborted,避免 AI 先于
|
|
11
11
|
* UI 触发 abort 时 comp 尚未创建导致 cancel 落空。
|
|
@@ -14,10 +14,16 @@
|
|
|
14
14
|
*
|
|
15
15
|
* fail-closed 原则:任何异常路径 → ask(交人工,不静默放行)。
|
|
16
16
|
* checkPermission 永不 throw(caller index.ts 的 tool_call handler 依赖此契约)。
|
|
17
|
+
*
|
|
18
|
+
* 白盒导出声明(E9):buildApprovalRequest / matchNonBashTool / runLayer2 /
|
|
19
|
+
* applyAutoApproveOverrides / runLayer3WithRacing 是内部测试 seam,非包公共 API
|
|
20
|
+
* (包 main 只导出工厂函数,外部消费者不存在)——pipeline.test.ts / e2e-modes.test.ts
|
|
21
|
+
* 白盒直测锚定安全语义(runLayer2 聚合表 / matchNonBashTool 非 bash 语义),故保留。
|
|
17
22
|
*/
|
|
18
23
|
|
|
24
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
19
25
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
20
|
-
import { matchRules } from "./rules/matcher.js";
|
|
26
|
+
import { lastMatchWins, matchRules, resolvePattern } from "./rules/matcher.js";
|
|
21
27
|
import { wildcardToRegExp } from "./rules/wildcard.js";
|
|
22
28
|
import type {
|
|
23
29
|
ApprovalRequest,
|
|
@@ -68,20 +74,20 @@ export function buildApprovalRequest(
|
|
|
68
74
|
/**
|
|
69
75
|
* G1 修正:非 bash 工具规则匹配 helper。
|
|
70
76
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* 故本模块自带 helper,不依赖 matchRules 的非 bash 路径。
|
|
77
|
+
* W5 需要让 Read/Write/Edit 也评估用户规则(用户可能写 `read ~/.ssh/*` 的 deny 规则),
|
|
78
|
+
* matcher 的 bash 入口不覆盖该场景,故本模块自带 helper。
|
|
74
79
|
*
|
|
75
80
|
* M5 修正:pattern 对 path 匹配(而非 toolName)。用户规则如 `read ~/.ssh/*` 的
|
|
76
81
|
* pattern `~/.ssh/*` 应对文件路径生效,对 toolName='read' 匹配是失效的。
|
|
77
82
|
* - tool 字段仍对 toolName 匹配(wildcard,支持 'read'/'write'/'*')。
|
|
78
83
|
* - pattern 字段对 path 匹配;path 为 undefined 时退化为对 toolName 匹配
|
|
79
84
|
* (兼容 rule.tool='read' pattern='*' 这种不关心路径的规则)。
|
|
80
|
-
* - pattern
|
|
81
|
-
*
|
|
85
|
+
* - pattern 双语义分发(builtin-danger RegExp 源串 vs 其余 wildcard)走
|
|
86
|
+
* matcher.resolvePattern 单源(E7/M10,含 patternCache 复用)。
|
|
82
87
|
* - 无匹配 → ask(与 G1 一致,deny 到下游)。
|
|
83
88
|
*
|
|
84
|
-
*
|
|
89
|
+
* 复用 wildcardToRegExp(tool 字段编译)/ resolvePattern / lastMatchWins,
|
|
90
|
+
* 不重新实现任何匹配语义。
|
|
85
91
|
*/
|
|
86
92
|
export function matchNonBashTool(
|
|
87
93
|
toolName: string,
|
|
@@ -92,20 +98,13 @@ export function matchNonBashTool(
|
|
|
92
98
|
return { action: "ask", matchedRule: undefined };
|
|
93
99
|
}
|
|
94
100
|
const matchTarget = path ?? toolName;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
rule.source === "builtin-danger" ? new RegExp(rule.pattern, "i") : wildcardToRegExp(rule.pattern);
|
|
103
|
-
// M5:pattern 对 path 匹配(path 缺省时对 toolName,'*' 总是命中)。
|
|
104
|
-
if (patternRe.test(matchTarget)) {
|
|
105
|
-
winner = { action: rule.action, matchedRule: rule };
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return winner;
|
|
101
|
+
// M5:pattern 对 path 匹配(path 缺省时对 toolName,'*' 总是命中);
|
|
102
|
+
// E11:last-match-wins 循环走 matcher 单源(tool 不匹配时短路,不编译 pattern)。
|
|
103
|
+
return lastMatchWins(
|
|
104
|
+
rules,
|
|
105
|
+
(rule) =>
|
|
106
|
+
wildcardToRegExp(rule.tool).test(toolName) && resolvePattern(rule).test(matchTarget),
|
|
107
|
+
);
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
/**
|
|
@@ -141,7 +140,7 @@ export function runLayer2(
|
|
|
141
140
|
if (argvList.length === 0) {
|
|
142
141
|
// bash 但 AST 没拆出命令(parseError 或空)→ 仍做 C1 完整字符串 deny 检查
|
|
143
142
|
// (命令本身可能含管道,AST 拆不出但 deny 规则能命中)
|
|
144
|
-
const fullCmdDeny = matchRules(
|
|
143
|
+
const fullCmdDeny = matchRules(command, rules);
|
|
145
144
|
if (fullCmdDeny.action === "deny") return fullCmdDeny;
|
|
146
145
|
return { action: "ask", matchedRule: undefined };
|
|
147
146
|
}
|
|
@@ -160,7 +159,7 @@ export function runLayer2(
|
|
|
160
159
|
// C1:对完整 command 字符串做 deny 补充检查(覆盖跨 argv 管道如 curl|sh)。
|
|
161
160
|
// 仅 deny 能覆盖 argv 级结果;完整字符串检查不查白名单(matchRules 语义),
|
|
162
161
|
// 故不会把 argv 级 deny/ask 提升为 allow。
|
|
163
|
-
const fullCmdDeny = matchRules(
|
|
162
|
+
const fullCmdDeny = matchRules(command, rules);
|
|
164
163
|
if (fullCmdDeny.action === "deny") return fullCmdDeny;
|
|
165
164
|
if (sawAsk) return { action: "ask", matchedRule: undefined };
|
|
166
165
|
return lastAllow ?? { action: "ask", matchedRule: undefined };
|
|
@@ -224,7 +223,7 @@ function startAiClassification(
|
|
|
224
223
|
signal: AbortSignal,
|
|
225
224
|
): Promise<ClassifierResult> {
|
|
226
225
|
return deps.classifier.classifyRisk(ctx, config, signal).catch((err: unknown) => {
|
|
227
|
-
const msg =
|
|
226
|
+
const msg = toErrorMessage(err);
|
|
228
227
|
return {
|
|
229
228
|
outcome: "ask" as const,
|
|
230
229
|
risk_level: "medium" as const,
|
|
@@ -554,8 +553,7 @@ async function runAstLayer(
|
|
|
554
553
|
* @param config classifier 配置(auto 模式用)
|
|
555
554
|
* @param userRules 用户规则(与 getDefaultRules 拼接)
|
|
556
555
|
* @param deps 注入依赖
|
|
557
|
-
* @param ctxBase 工具调用上下文基线(cwd/agentName)
|
|
558
|
-
* @param signal 外层 abort signal
|
|
556
|
+
* @param ctxBase 工具调用上下文基线(cwd/agentName,含 signal)
|
|
559
557
|
*/
|
|
560
558
|
export async function checkPermission(
|
|
561
559
|
toolName: string,
|
|
@@ -589,7 +587,7 @@ export async function checkPermission(
|
|
|
589
587
|
const rules = [...deps.getDefaultRules(), ...userRules];
|
|
590
588
|
// C1:传入完整 command(bash 跨 argv 管道 deny 补充检查);
|
|
591
589
|
// M5:传入 path(非 bash 工具规则对 path 匹配)。
|
|
592
|
-
const layer2 =
|
|
590
|
+
const layer2 = runLayer2(toolName, ast.argvList, rules, deps.matchRulesForArgv, command, ctx.path);
|
|
593
591
|
|
|
594
592
|
const decided = ruleDecision(layer2);
|
|
595
593
|
if (decided !== null) {
|
|
@@ -606,21 +604,6 @@ export async function checkPermission(
|
|
|
606
604
|
return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal);
|
|
607
605
|
}
|
|
608
606
|
|
|
609
|
-
/**
|
|
610
|
-
* 层 2 规则匹配(用注入的 deps.matchRulesForArgv)。
|
|
611
|
-
* 薄封装 runLayer2,把注入的 matcher 传入纯逻辑版。
|
|
612
|
-
*/
|
|
613
|
-
function runLayer2ForArgvList(
|
|
614
|
-
toolName: string,
|
|
615
|
-
argvList: string[][],
|
|
616
|
-
rules: readonly Rule[],
|
|
617
|
-
deps: CheckPermissionDeps,
|
|
618
|
-
command: string | undefined,
|
|
619
|
-
path: string | undefined,
|
|
620
|
-
): RuleMatchResult {
|
|
621
|
-
return runLayer2(toolName, argvList, rules, deps.matchRulesForArgv, command, path);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
607
|
/** approve/strict 模式的人工审批封装(无 AI)。 */
|
|
625
608
|
async function askUser(
|
|
626
609
|
deps: CheckPermissionDeps,
|
package/src/rule-editor.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* W8: Rule editor overlay — three-mode dispatch (TUI / RPC / headless).
|
|
3
3
|
*
|
|
4
4
|
* editRulesViaOverlay:主入口,按 ctx.mode 分发。
|
|
5
|
-
* editViaRpc:RPC 循环(list → action → fill →
|
|
5
|
+
* editViaRpc:RPC 循环(list → action → fill → ...)。
|
|
6
6
|
* G16:RPC listOptions 用 applyOps(initialRules, ops) 重建。
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -12,9 +12,6 @@ import type { Rule } from "./types.js";
|
|
|
12
12
|
|
|
13
13
|
// ──────────────────────── 类型 ────────────────────────
|
|
14
14
|
|
|
15
|
-
/** RuleOp re-export(rule-templates.ts 定义)。 */
|
|
16
|
-
export type { RuleOp } from "./rule-templates.js";
|
|
17
|
-
|
|
18
15
|
/** 编辑结果:RuleOp[](有变更)或 undefined(cancel / headless 降级)。 */
|
|
19
16
|
export type RuleEditorResult = RuleOp[] | undefined;
|
|
20
17
|
|
|
@@ -33,12 +30,6 @@ export interface RuleEditorContext {
|
|
|
33
30
|
};
|
|
34
31
|
}
|
|
35
32
|
|
|
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
33
|
// ──────────────────────── editRulesViaOverlay(主入口) ────────────────────────
|
|
43
34
|
|
|
44
35
|
/**
|
|
@@ -47,20 +38,18 @@ export interface RuleEditorRpcDeps {
|
|
|
47
38
|
* @param ctx UI 上下文(mode + ui.*)
|
|
48
39
|
* @param initialRules 当前 userRules
|
|
49
40
|
* @param sessionIdCounter user-N id 生成器闭包
|
|
50
|
-
* @param rpcDeps 可选 RPC 依赖注入(测试 mock 用)
|
|
51
41
|
* @returns RuleOp[](有变更)或 undefined(cancel / headless)
|
|
52
42
|
*/
|
|
53
43
|
export async function editRulesViaOverlay(
|
|
54
44
|
ctx: RuleEditorContext,
|
|
55
45
|
initialRules: readonly Rule[],
|
|
56
46
|
sessionIdCounter: () => string,
|
|
57
|
-
rpcDeps?: RuleEditorRpcDeps,
|
|
58
47
|
): Promise<RuleEditorResult> {
|
|
59
48
|
switch (ctx.mode) {
|
|
60
49
|
case "tui":
|
|
61
50
|
return await editViaTui(ctx, initialRules, sessionIdCounter);
|
|
62
51
|
case "rpc":
|
|
63
|
-
return await editViaRpc(ctx, initialRules, sessionIdCounter
|
|
52
|
+
return await editViaRpc(ctx, initialRules, sessionIdCounter);
|
|
64
53
|
case "json":
|
|
65
54
|
case "print":
|
|
66
55
|
default:
|
|
@@ -102,11 +91,10 @@ interface RpcIo {
|
|
|
102
91
|
* ctx.ui.input 可用 → 用真实文本输入(custom 模板需要自由文本);
|
|
103
92
|
* 不可用 → fallback 把 input 降级为单选项 select(占位符文本,仅保底)。
|
|
104
93
|
*/
|
|
105
|
-
function resolveRpcInput(ctx: RuleEditorContext
|
|
106
|
-
return
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
: (title: string, ph?: string) => ctx.ui.select(title, [ph ?? ""]).then((v) => v === undefined ? undefined : v));
|
|
94
|
+
function resolveRpcInput(ctx: RuleEditorContext): RpcIo["input"] {
|
|
95
|
+
return typeof ctx.ui.input === "function"
|
|
96
|
+
? (title: string, ph?: string) => ctx.ui.input!(title, ph)
|
|
97
|
+
: (title: string, ph?: string) => ctx.ui.select(title, [ph ?? ""]).then((v) => v === undefined ? undefined : v);
|
|
110
98
|
}
|
|
111
99
|
|
|
112
100
|
/** 单轮 RPC list 画面:当前规则集(applyOps 反映前序操作)+ 标签 + 选项。 */
|
|
@@ -185,17 +173,15 @@ async function runRpcLoop(
|
|
|
185
173
|
* RPC 循环编辑:list → action select → fill input → ... 直到 Done / cancel。
|
|
186
174
|
*
|
|
187
175
|
* G16:每次 op 后 listOptions 更新(反映前序操作)。
|
|
188
|
-
* deps 注入 select/input 便于测试 mock。
|
|
189
176
|
*/
|
|
190
177
|
export async function editViaRpc(
|
|
191
178
|
ctx: RuleEditorContext,
|
|
192
179
|
initialRules: readonly Rule[],
|
|
193
180
|
sessionIdCounter: () => string,
|
|
194
|
-
rpcDeps?: RuleEditorRpcDeps,
|
|
195
181
|
): Promise<RuleEditorResult> {
|
|
196
182
|
const io: RpcIo = {
|
|
197
|
-
select:
|
|
198
|
-
input: resolveRpcInput(ctx
|
|
183
|
+
select: ctx.ui.select,
|
|
184
|
+
input: resolveRpcInput(ctx),
|
|
199
185
|
};
|
|
200
186
|
return await runRpcLoop(io, initialRules, sessionIdCounter);
|
|
201
187
|
}
|
|
@@ -1,26 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* IT 系列:rules/index.ts barrel 导出验证。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* E10 收敛后 barrel 只保留有生产消费的 2 符号(getDefaultRules / matchRulesForArgv);
|
|
5
|
+
* 其余符号的行为测试走深路径(matcher.test.ts / builtins.test.ts / wildcard.test.ts)。
|
|
5
6
|
*/
|
|
6
7
|
import { describe, expect, it } from "vitest";
|
|
7
8
|
|
|
8
9
|
import * as rulesApi from "../index.js";
|
|
9
10
|
|
|
10
11
|
describe("IT: rules barrel 导出", () => {
|
|
11
|
-
it("
|
|
12
|
-
expect(
|
|
13
|
-
expect(typeof rulesApi.matchRulesForArgv).toBe("function");
|
|
14
|
-
expect(typeof rulesApi.resolvePattern).toBe("function");
|
|
12
|
+
it("只导出 2 个有生产消费的符号", () => {
|
|
13
|
+
expect(Object.keys(rulesApi).sort()).toEqual(["getDefaultRules", "matchRulesForArgv"]);
|
|
15
14
|
expect(typeof rulesApi.getDefaultRules).toBe("function");
|
|
16
|
-
expect(typeof rulesApi.
|
|
17
|
-
expect(typeof rulesApi.findGitSubcommand).toBe("function");
|
|
18
|
-
expect(typeof rulesApi.wildcardToRegExp).toBe("function");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("导出常量数据(BUILTIN_DANGER_RULES / BUILTIN_UNCONDITIONAL_SAFE)", () => {
|
|
22
|
-
expect(Array.isArray(rulesApi.BUILTIN_DANGER_RULES)).toBe(true);
|
|
23
|
-
expect(rulesApi.BUILTIN_DANGER_RULES.length).toBe(12);
|
|
24
|
-
expect(rulesApi.BUILTIN_UNCONDITIONAL_SAFE.size).toBe(50);
|
|
15
|
+
expect(typeof rulesApi.matchRulesForArgv).toBe("function");
|
|
25
16
|
});
|
|
26
17
|
});
|
|
@@ -11,13 +11,8 @@
|
|
|
11
11
|
import { describe, expect, it } from "vitest";
|
|
12
12
|
|
|
13
13
|
import type { Rule } from "../../types.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
getDefaultRules,
|
|
17
|
-
matchRules,
|
|
18
|
-
matchRulesForArgv,
|
|
19
|
-
resolvePattern,
|
|
20
|
-
} from "../index.js";
|
|
14
|
+
import { BUILTIN_DANGER_RULES, getDefaultRules } from "../builtins.js";
|
|
15
|
+
import { matchRules, matchRulesForArgv, resolvePattern } from "../matcher.js";
|
|
21
16
|
|
|
22
17
|
// 构造 user 规则的辅助
|
|
23
18
|
function userRule(id: string, pattern: string, action: Rule["action"] = "allow"): Rule {
|
|
@@ -37,7 +32,7 @@ describe("MT-G1: no-match 返回 ask(非 deny)", () => {
|
|
|
37
32
|
|
|
38
33
|
it("matchRules 无匹配 → ask", () => {
|
|
39
34
|
const rules: Rule[] = [userRule("u1", "npm *")];
|
|
40
|
-
const r = matchRules("
|
|
35
|
+
const r = matchRules("ls", rules);
|
|
41
36
|
expect(r.action).toBe("ask");
|
|
42
37
|
expect(r.matchedRule).toBeUndefined();
|
|
43
38
|
});
|
|
@@ -119,7 +114,7 @@ describe("MT-G3: matchRulesForArgv 白名单兜底(C3:不再短路,user de
|
|
|
119
114
|
|
|
120
115
|
it("matchRules(退化路径)不调 isKnownSafeCommand —— command 字符串", () => {
|
|
121
116
|
// matchRules 不查白名单,直接对 command 字符串做正则/wildcard 匹配
|
|
122
|
-
const r = matchRules("
|
|
117
|
+
const r = matchRules("ls -la", []);
|
|
123
118
|
expect(r.action).toBe("ask"); // 无规则匹配 → ask(不查白名单)
|
|
124
119
|
});
|
|
125
120
|
|
|
@@ -178,30 +173,28 @@ describe("MT-last-match-wins: 后匹配的规则胜出", () => {
|
|
|
178
173
|
// ──────────────────────── matchRules 退化路径 ────────────────────────
|
|
179
174
|
|
|
180
175
|
describe("MT-matchRules: 退化路径(command 字符串)", () => {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
expect(r.action).toBe("ask");
|
|
184
|
-
});
|
|
176
|
+
// E11:toolName 参数与 '!== bash' 守卫已删(生产调用点恒在 bash 分支内,
|
|
177
|
+
// 非 bash 由 pipeline.matchNonBashTool 承接),对应守卫用例随之移除。
|
|
185
178
|
|
|
186
179
|
it("command === undefined → ask", () => {
|
|
187
|
-
const r = matchRules(
|
|
180
|
+
const r = matchRules(undefined, getDefaultRules());
|
|
188
181
|
expect(r.action).toBe("ask");
|
|
189
182
|
});
|
|
190
183
|
|
|
191
184
|
it("command 空字符串 → ask", () => {
|
|
192
|
-
const r = matchRules("
|
|
185
|
+
const r = matchRules("", getDefaultRules());
|
|
193
186
|
expect(r.action).toBe("ask");
|
|
194
187
|
});
|
|
195
188
|
|
|
196
189
|
it("command 命中 builtin-danger 正则 → deny", () => {
|
|
197
|
-
const r = matchRules("
|
|
190
|
+
const r = matchRules("rm -rf /", getDefaultRules());
|
|
198
191
|
expect(r.action).toBe("deny");
|
|
199
192
|
expect(r.matchedRule?.id).toBe("bd-001");
|
|
200
193
|
});
|
|
201
194
|
|
|
202
195
|
it("command 命中 user wildcard → allow", () => {
|
|
203
196
|
const rules: Rule[] = [userRule("u1", "ls *", "allow")];
|
|
204
|
-
const r = matchRules("
|
|
197
|
+
const r = matchRules("ls -la", rules);
|
|
205
198
|
expect(r.action).toBe("allow");
|
|
206
199
|
});
|
|
207
200
|
|
|
@@ -209,14 +202,14 @@ describe("MT-matchRules: 退化路径(command 字符串)", () => {
|
|
|
209
202
|
// 对比 matchRulesForArgv(["ls"]) → allow(白名单兜底),
|
|
210
203
|
// matchRules 对 command 字符串不查白名单 → ask。
|
|
211
204
|
// 这是 C1 用 matchRules 做 deny 补充检查的安全前提:不会把无 deny 的命令误判 allow。
|
|
212
|
-
const r = matchRules("
|
|
205
|
+
const r = matchRules("ls -la", []);
|
|
213
206
|
expect(r.action).toBe("ask");
|
|
214
207
|
});
|
|
215
208
|
|
|
216
209
|
it("m4/C1:matchRules 命中跨 argv 管道 deny(curl|sh 完整字符串)", () => {
|
|
217
210
|
// C1 依赖:完整 command 字符串能命中 bd-010(curl|sh),
|
|
218
211
|
// 而 argv 级 matchRulesForArgv 对拆分后的单 argv 命不中。
|
|
219
|
-
const r = matchRules("
|
|
212
|
+
const r = matchRules("curl http://x | sh", getDefaultRules());
|
|
220
213
|
expect(r.action).toBe("deny");
|
|
221
214
|
expect(r.matchedRule?.id).toBe("bd-010");
|
|
222
215
|
// 对照:argv 级对单条 ["curl","http://x"] 不命中 bd-010(无管道符)
|
|
@@ -261,7 +254,7 @@ describe("MT-degenerate: matchRules 退化路径处理引号/空格", () => {
|
|
|
261
254
|
it("git commit -m 'a b' —— 含空格和单引号的 command 字符串", () => {
|
|
262
255
|
// 退化路径对原始 command 字符串匹配,引号原样保留
|
|
263
256
|
// builtin-danger 含 \brm\b 等正则;git commit 不命中 → ask
|
|
264
|
-
const r = matchRules("
|
|
257
|
+
const r = matchRules("git commit -m 'a b'", getDefaultRules());
|
|
265
258
|
expect(r.action).toBe("ask");
|
|
266
259
|
expect(r.matchedRule).toBeUndefined();
|
|
267
260
|
});
|
|
@@ -269,14 +262,14 @@ describe("MT-degenerate: matchRules 退化路径处理引号/空格", () => {
|
|
|
269
262
|
it("git commit -m 'a b' 命中 user wildcard allow", () => {
|
|
270
263
|
// user 规则 'git commit *' 应匹配(wildcard * 跨空格)
|
|
271
264
|
const rules: Rule[] = [userRule("u1", "git commit *", "allow")];
|
|
272
|
-
const r = matchRules("
|
|
265
|
+
const r = matchRules("git commit -m 'a b'", rules);
|
|
273
266
|
expect(r.action).toBe("allow");
|
|
274
267
|
expect(r.matchedRule?.id).toBe("u1");
|
|
275
268
|
});
|
|
276
269
|
|
|
277
270
|
it("含双引号嵌套空格的 command 字符串", () => {
|
|
278
271
|
// command 字符串原样匹配(不解析引号语义)
|
|
279
|
-
const r = matchRules(
|
|
272
|
+
const r = matchRules('echo "hello world"', getDefaultRules());
|
|
280
273
|
// echo 命中 builtin-danger?查实际行为:echo 不在 danger 列表 → ask
|
|
281
274
|
expect(r.action).toBe("ask");
|
|
282
275
|
});
|
|
@@ -364,7 +357,7 @@ describe("MT-contract: no-match ask 语义契约", () => {
|
|
|
364
357
|
});
|
|
365
358
|
|
|
366
359
|
it("matchRules no-match 永远返回 { action:'ask', matchedRule:undefined }", () => {
|
|
367
|
-
const r = matchRules("
|
|
360
|
+
const r = matchRules("nonexistent-command --flag", []);
|
|
368
361
|
expect(r).toEqual({ action: "ask", matchedRule: undefined });
|
|
369
362
|
});
|
|
370
363
|
|
package/src/rules/index.ts
CHANGED
|
@@ -1,27 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* rules 模块 barrel —— 只 re-export
|
|
2
|
+
* rules 模块 barrel —— 只 re-export 有真实消费的公开 API(E10 收敛)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - wildcardToRegExp:wildcard 编译(测试用)
|
|
9
|
-
* - findGitSubcommand:git 子命令提取(测试用)
|
|
10
|
-
*
|
|
11
|
-
* 类型从 ../types.js re-export(Rule / RuleMatchResult / PermissionAction 等)。
|
|
4
|
+
* 生产消费面 = production.ts:getDefaultRules(内置规则拼接)+ matchRulesForArgv
|
|
5
|
+
* (bash argv 匹配)。其余符号(matchRules / resolvePattern / wildcardToRegExp /
|
|
6
|
+
* builtins 数据)无 barrel 消费——pipeline.ts 与各测试均走深路径 import;
|
|
7
|
+
* 类型一律直接从 ../types.js import,barrel 不做类型 re-export。
|
|
12
8
|
*/
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
Rule,
|
|
16
|
-
RuleMatchResult,
|
|
17
|
-
RuleSource,
|
|
18
|
-
} from "../types.js";
|
|
19
|
-
export {
|
|
20
|
-
BUILTIN_DANGER_RULES,
|
|
21
|
-
BUILTIN_UNCONDITIONAL_SAFE,
|
|
22
|
-
findGitSubcommand,
|
|
23
|
-
getDefaultRules,
|
|
24
|
-
isKnownSafeCommand,
|
|
25
|
-
} from "./builtins.js";
|
|
26
|
-
export { matchRules, matchRulesForArgv, resolvePattern } from "./matcher.js";
|
|
27
|
-
export { wildcardToRegExp } from "./wildcard.js";
|
|
9
|
+
export { getDefaultRules } from "./builtins.js";
|
|
10
|
+
export { matchRulesForArgv } from "./matcher.js";
|
package/src/rules/matcher.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* 再查 isKnownSafeCommand(argv) 兜底返回 allow。白名单不再短路:用户 deny 规则
|
|
19
19
|
* 可覆盖白名单。白名单不进 Rule[] 数组(Codex flag 子检查无法用单条 wildcard 表达)。
|
|
20
20
|
*
|
|
21
|
-
* 匹配语义:last-match-wins(rules 数组顺序遍历,最后匹配的 rule
|
|
21
|
+
* 匹配语义:last-match-wins(rules 数组顺序遍历,最后匹配的 rule 胜出),
|
|
22
|
+
* 循环单源在 lastMatchWins(E11,pipeline.matchNonBashTool 也消费)。
|
|
22
23
|
* 拼接顺序由调用方(W5)负责:[...builtin-danger, ...userRules]。
|
|
23
24
|
*/
|
|
24
25
|
|
|
@@ -58,6 +59,28 @@ export function resolvePattern(rule: Rule): RegExp {
|
|
|
58
59
|
return re;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
// ──────────────────────── lastMatchWins(循环单源,E11) ────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* last-match-wins 循环单源(E11:原三份循环——matchRulesForArgv / matchRules /
|
|
66
|
+
* pipeline.matchNonBashTool——收敛)。
|
|
67
|
+
*
|
|
68
|
+
* 按 rules 数组顺序遍历,predicate 命中时记录 winner,最后匹配者胜出;
|
|
69
|
+
* 无匹配返回 `{ action: 'ask', matchedRule: undefined }`(G1 兜底)。
|
|
70
|
+
*/
|
|
71
|
+
export function lastMatchWins(
|
|
72
|
+
rules: readonly Rule[],
|
|
73
|
+
predicate: (rule: Rule) => boolean,
|
|
74
|
+
): RuleMatchResult {
|
|
75
|
+
let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
if (predicate(rule)) {
|
|
78
|
+
winner = { action: rule.action, matchedRule: rule };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return winner;
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
// ──────────────────────── matchRulesForArgv(happy path,W2 clean=true 有 argv[])────────────────────────
|
|
62
85
|
|
|
63
86
|
/** 命中 isKnownSafeCommand 时构造的虚拟 builtin-safe rule(保持 matchedRule 字段一致性)。 */
|
|
@@ -95,13 +118,7 @@ export function matchRulesForArgv(argv: string[], rules: readonly Rule[]): RuleM
|
|
|
95
118
|
|
|
96
119
|
const commandStr = argv.join(" ");
|
|
97
120
|
|
|
98
|
-
|
|
99
|
-
for (const rule of rules) {
|
|
100
|
-
const re = resolvePattern(rule);
|
|
101
|
-
if (re.test(commandStr)) {
|
|
102
|
-
winner = { action: rule.action, matchedRule: rule };
|
|
103
|
-
}
|
|
104
|
-
}
|
|
121
|
+
const winner = lastMatchWins(rules, (rule) => resolvePattern(rule).test(commandStr));
|
|
105
122
|
|
|
106
123
|
// deny / allow 直接由 winner 决定(用户规则可覆盖白名单)
|
|
107
124
|
if (winner.action === "deny" || winner.action === "allow") {
|
|
@@ -128,33 +145,23 @@ export function matchRulesForArgv(argv: string[], rules: readonly Rule[]): RuleM
|
|
|
128
145
|
* 管道拆分后单个 argv 不含 `|`,deny 规则的正则无法命中)。
|
|
129
146
|
*
|
|
130
147
|
* 行为:
|
|
131
|
-
* - toolName !== 'bash' → ask(W3 只管 bash,其他工具交下游)
|
|
132
148
|
* - command === undefined / 空字符串 → ask
|
|
133
149
|
* - 遍历 rules(last-match-wins),对每条用 resolvePattern(rule).test(command)
|
|
134
150
|
* - 无匹配 → ask(G1)
|
|
135
151
|
*
|
|
152
|
+
* E11:toolName 参数与 `toolName !== 'bash'` 守卫已删——生产调用点(pipeline.runLayer2)
|
|
153
|
+
* 恒在 bash 分支内调用,非 bash 由 matchNonBashTool 承接,守卫是不可达的投机分支。
|
|
154
|
+
*
|
|
136
155
|
* 不调 isKnownSafeCommand(不查白名单):command 字符串无法可靠提取 argv
|
|
137
156
|
* (含引号/转义/管道),argv 级白名单判断由 matchRulesForArgv 在 happy path 负责。
|
|
138
157
|
* 因此本函数仅用于 deny 补充检查或退化路径,不用作 allow 放行依据。
|
|
139
158
|
*/
|
|
140
159
|
export function matchRules(
|
|
141
|
-
toolName: string,
|
|
142
160
|
command: string | undefined,
|
|
143
161
|
rules: readonly Rule[],
|
|
144
162
|
): RuleMatchResult {
|
|
145
|
-
if (toolName !== "bash") {
|
|
146
|
-
return { action: "ask", matchedRule: undefined };
|
|
147
|
-
}
|
|
148
163
|
if (command === undefined || command === "") {
|
|
149
164
|
return { action: "ask", matchedRule: undefined };
|
|
150
165
|
}
|
|
151
|
-
|
|
152
|
-
let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
|
|
153
|
-
for (const rule of rules) {
|
|
154
|
-
const re = resolvePattern(rule);
|
|
155
|
-
if (re.test(command)) {
|
|
156
|
-
winner = { action: rule.action, matchedRule: rule };
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return winner;
|
|
166
|
+
return lastMatchWins(rules, (rule) => resolvePattern(rule).test(command));
|
|
160
167
|
}
|