@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/approval.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* TUI 分支因 pi-tui Input 组件集成成本较高,暂保留简化 deny(TODO 后续迭代)。
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { type Component, matchesKey,
|
|
23
|
+
import { type Component, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
24
24
|
|
|
25
25
|
import type { ApprovalRequest } from "./pipeline.js";
|
|
26
26
|
import type { ToolInvocationContext, UserDecision } from "./types.js";
|
|
@@ -198,20 +198,65 @@ function requestHeadless(req: ApprovalRequest, approvalCtx: ApprovalContext): Us
|
|
|
198
198
|
return { approved: false, reason: `headless mode (${approvalCtx.mode}): cannot prompt, auto-deny` };
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
// ────────────────────────
|
|
201
|
+
// ──────────────────────── 辅助:字段集内核 + 标题格式化 ────────────────────────
|
|
202
202
|
|
|
203
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* E6(M6/D5):审批卡字段集(内核,唯一字段来源)。
|
|
205
|
+
*
|
|
206
|
+
* 「审批卡显示哪些内容」由 buildApprovalFieldSet 单点决定;两外壳只做排版——
|
|
207
|
+
* formatTitle(RPC)压紧凑行数组(AI 单行摘要 + reasoning 行),renderApprovalView
|
|
208
|
+
* (TUI)展开缩进块。排版是两外壳的形态差异,不属双写。
|
|
209
|
+
*/
|
|
210
|
+
interface ApprovalFieldSet {
|
|
211
|
+
title: string;
|
|
212
|
+
tool: string;
|
|
213
|
+
command: string | undefined;
|
|
214
|
+
reason: string;
|
|
215
|
+
ai?: {
|
|
216
|
+
risk: string;
|
|
217
|
+
outcome: string;
|
|
218
|
+
confidence: number;
|
|
219
|
+
reasoning: string;
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 构造审批卡字段集(title/tool/command/reason + 可选 AI 四元组含 reasoning)。 */
|
|
224
|
+
function buildApprovalFieldSet(req: ApprovalRequest): ApprovalFieldSet {
|
|
225
|
+
return {
|
|
226
|
+
title: "[pi-permission] Approval required",
|
|
227
|
+
tool: req.toolName,
|
|
228
|
+
// 空字符串与 undefined 同义(两外壳的 Command 行都只在非空时渲染)
|
|
229
|
+
command: req.command !== undefined && req.command.length > 0 ? req.command : undefined,
|
|
230
|
+
reason: req.reason,
|
|
231
|
+
...(req.preClassification
|
|
232
|
+
? {
|
|
233
|
+
ai: {
|
|
234
|
+
risk: req.preClassification.risk_level,
|
|
235
|
+
outcome: req.preClassification.outcome,
|
|
236
|
+
confidence: req.preClassification.confidence,
|
|
237
|
+
reasoning: req.preClassification.reasoning,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
: {}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 格式化审批标题(RPC select)。消费字段集,压成紧凑行数组(6 行)。
|
|
246
|
+
* M6 漂移修复:补 reasoning 行——此前 RPC 用户看不到 AI 理由(TUI 一直有)。
|
|
247
|
+
*/
|
|
204
248
|
function formatTitle(req: ApprovalRequest): string {
|
|
249
|
+
const fields = buildApprovalFieldSet(req);
|
|
205
250
|
const lines: string[] = [];
|
|
206
|
-
lines.push(
|
|
207
|
-
lines.push(`Tool: ${
|
|
208
|
-
if (
|
|
209
|
-
lines.push(`Command: ${
|
|
251
|
+
lines.push(fields.title);
|
|
252
|
+
lines.push(`Tool: ${fields.tool}`);
|
|
253
|
+
if (fields.command !== undefined) {
|
|
254
|
+
lines.push(`Command: ${fields.command}`);
|
|
210
255
|
}
|
|
211
|
-
lines.push(`Reason: ${
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
lines.push(`
|
|
256
|
+
lines.push(`Reason: ${fields.reason}`);
|
|
257
|
+
if (fields.ai) {
|
|
258
|
+
lines.push(`AI: risk=${fields.ai.risk} outcome=${fields.ai.outcome} (conf=${fields.ai.confidence})`);
|
|
259
|
+
lines.push(` reasoning: ${fields.ai.reasoning}`);
|
|
215
260
|
}
|
|
216
261
|
return lines.join("\n");
|
|
217
262
|
}
|
|
@@ -260,11 +305,6 @@ export class ApprovalComponent implements Component {
|
|
|
260
305
|
this.cachedLines = undefined;
|
|
261
306
|
}
|
|
262
307
|
|
|
263
|
-
private rerender(): void {
|
|
264
|
-
this.invalidate();
|
|
265
|
-
this.tui.requestRender();
|
|
266
|
-
}
|
|
267
|
-
|
|
268
308
|
render(width: number): string[] {
|
|
269
309
|
if (this.cachedWidth === width && this.cachedLines) {
|
|
270
310
|
return this.cachedLines;
|
|
@@ -316,25 +356,25 @@ export class ApprovalComponent implements Component {
|
|
|
316
356
|
/** box 边框左右各占用 1 列(│ × 2) */
|
|
317
357
|
const BORDER_OVERHEAD = 2;
|
|
318
358
|
|
|
319
|
-
/**
|
|
359
|
+
/** 渲染审批视图(纯函数,便于单测)。消费字段集(E6 内核),排版为 TUI 展开块。 */
|
|
320
360
|
export function renderApprovalView(req: ApprovalRequest, width: number): string[] {
|
|
361
|
+
const fields = buildApprovalFieldSet(req);
|
|
321
362
|
const innerWidth = Math.max(0, width - BORDER_OVERHEAD); // 减去左右边框
|
|
322
363
|
const inner: string[] = [];
|
|
323
|
-
inner.push(
|
|
364
|
+
inner.push(fields.title);
|
|
324
365
|
inner.push("");
|
|
325
|
-
inner.push(`Tool: ${
|
|
326
|
-
if (
|
|
327
|
-
inner.push(`Command: ${
|
|
366
|
+
inner.push(`Tool: ${fields.tool}`);
|
|
367
|
+
if (fields.command !== undefined) {
|
|
368
|
+
inner.push(`Command: ${fields.command}`);
|
|
328
369
|
}
|
|
329
|
-
inner.push(`Reason: ${
|
|
330
|
-
if (
|
|
331
|
-
const pc = req.preClassification;
|
|
370
|
+
inner.push(`Reason: ${fields.reason}`);
|
|
371
|
+
if (fields.ai) {
|
|
332
372
|
inner.push("");
|
|
333
373
|
inner.push(`AI classification:`);
|
|
334
|
-
inner.push(` risk: ${
|
|
335
|
-
inner.push(` outcome: ${
|
|
336
|
-
inner.push(` confidence: ${
|
|
337
|
-
inner.push(` reasoning: ${
|
|
374
|
+
inner.push(` risk: ${fields.ai.risk}`);
|
|
375
|
+
inner.push(` outcome: ${fields.ai.outcome}`);
|
|
376
|
+
inner.push(` confidence: ${fields.ai.confidence}`);
|
|
377
|
+
inner.push(` reasoning: ${fields.ai.reasoning}`);
|
|
338
378
|
}
|
|
339
379
|
inner.push("");
|
|
340
380
|
inner.push("[Enter] Approve [Esc] Deny");
|
|
@@ -349,6 +389,3 @@ export function renderApprovalView(req: ApprovalRequest, width: number): string[
|
|
|
349
389
|
lines.push(`└${"─".repeat(innerWidth)}┘`);
|
|
350
390
|
return lines;
|
|
351
391
|
}
|
|
352
|
-
|
|
353
|
-
// re-export SelectItem 仅为测试便利(mock ctx.ui.select 时构造选项)
|
|
354
|
-
export type { SelectItem };
|
package/src/ast/analyzer.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { Node, Tree } from "web-tree-sitter";
|
|
22
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
22
23
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
23
24
|
|
|
24
25
|
import type { BashAnalysis } from "../types.js";
|
|
@@ -245,7 +246,7 @@ export async function analyzeBashStructure(command: string): Promise<BashAnalysi
|
|
|
245
246
|
};
|
|
246
247
|
} catch (err) {
|
|
247
248
|
// 任何意外异常 → fail-closed
|
|
248
|
-
const msg =
|
|
249
|
+
const msg = toErrorMessage(err);
|
|
249
250
|
logger.warn("analyzeBashStructure exception", { error: msg });
|
|
250
251
|
return failClosed(msg || "EXCEPTION");
|
|
251
252
|
} finally {
|
package/src/ast/loader.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { dirname, join } from "node:path";
|
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
|
|
27
27
|
import { Language, Parser } from "web-tree-sitter";
|
|
28
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
28
29
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
29
30
|
|
|
30
31
|
const logger = getLogger("pi-permission/ast");
|
|
@@ -121,7 +122,7 @@ export function getBashParser(): Promise<Parser | null> {
|
|
|
121
122
|
} catch (err) {
|
|
122
123
|
// fail-closed:清空缓存让下次调用重试,warn 后返回 null。
|
|
123
124
|
parserPromise = null;
|
|
124
|
-
const msg =
|
|
125
|
+
const msg = toErrorMessage(err);
|
|
125
126
|
logger.warn("getBashParser init failed", { error: msg });
|
|
126
127
|
return null;
|
|
127
128
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { Api, Message, Model } from "@earendil-works/pi-ai";
|
|
21
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
21
22
|
import type { CallLLMOptions, CallLLMResult } from "@zhushanwen/pi-llm-shared";
|
|
22
23
|
|
|
23
24
|
import type {
|
|
@@ -90,7 +91,7 @@ function raceResultWithDeadline(
|
|
|
90
91
|
(result) => ({ kind: "ok" as const, result }),
|
|
91
92
|
(error) => ({
|
|
92
93
|
kind: "ok" as const,
|
|
93
|
-
result: { ok: false as const, error:
|
|
94
|
+
result: { ok: false as const, error: toErrorMessage(error) },
|
|
94
95
|
}),
|
|
95
96
|
),
|
|
96
97
|
];
|
|
@@ -229,7 +230,7 @@ export function createClassifier(deps: ClassifierDeps): {
|
|
|
229
230
|
try {
|
|
230
231
|
settled = await raceResultWithDeadline(callPromise, timeoutMs, signal);
|
|
231
232
|
} catch (error) {
|
|
232
|
-
const message =
|
|
233
|
+
const message = toErrorMessage(error);
|
|
233
234
|
onLog?.(`[pi-permission] classifier: callLLM race threw: ${message}`);
|
|
234
235
|
return { ...CLASSIFY_FALLBACK_RESULT };
|
|
235
236
|
}
|
package/src/classifier/index.ts
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AI Classifier barrel(层 3
|
|
2
|
+
* AI Classifier barrel(层 3 入口)——只暴露有真实消费的 2 符号(E10 收敛)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 唯一生产消费者 production.ts:createClassifier(+ 装配所需的 ClassifierDeps 类型)。
|
|
5
|
+
* prompt / json-parser / model-resolver 的消费方与测试均走深路径 import;
|
|
6
|
+
* 类型一律直接从 ../types.js import,barrel 不做类型 re-export。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export type { ClassifierDeps } from "./classifier.js";
|
|
10
10
|
export { createClassifier } from "./classifier.js";
|
|
11
|
-
export { PARSE_FALLBACK_RESULT,parseClassifierResponse } from "./json-parser.js";
|
|
12
|
-
export type { ListAvailableModelsCtx, ResolvedModelEntry } from "./model-resolver.js";
|
|
13
|
-
export { listAvailableModels } from "./model-resolver.js";
|
|
14
|
-
export { buildClassifierUserPrompt,CLASSIFIER_SYSTEM_PROMPT } from "./prompt.js";
|
|
15
|
-
|
|
16
|
-
// 类型 re-export(单一来源:types.ts)
|
|
17
|
-
export type {
|
|
18
|
-
ClassifierConfig,
|
|
19
|
-
ClassifierResult,
|
|
20
|
-
RiskLevel,
|
|
21
|
-
ToolInvocationContext,
|
|
22
|
-
} from "../types.js";
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* hasConfiguredAuth() 过滤——用户只经 `pi auth login` 配的内置/OAuth provider 同样可见,
|
|
9
9
|
* 不再自读 models.json(loadModelsJson / flattenModels 已删除)。
|
|
10
10
|
* E1:ResolvedModelEntry.apiKey 死字段已删(P3 收口后凭证走 modelRegistry,无消费者)。
|
|
11
|
+
* E5:name/baseUrl/cost 死字段已删(picker 只读 id/api)。
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
@@ -27,27 +28,17 @@ export interface ListAvailableModelsCtx {
|
|
|
27
28
|
|
|
28
29
|
// ──────────────────────── 条目类型 ────────────────────────
|
|
29
30
|
|
|
30
|
-
/** 单个 model 的 cost 结构(与 pi-ai Model.cost 同形;缺失时填零默认) */
|
|
31
|
-
interface ModelCost {
|
|
32
|
-
input: number;
|
|
33
|
-
output: number;
|
|
34
|
-
cacheRead: number;
|
|
35
|
-
cacheWrite: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
31
|
/**
|
|
39
32
|
* 解析后的「扁平 model 条目」:附带其 provider 名。
|
|
40
33
|
*
|
|
41
34
|
* listAvailableModels 的中间表示,用于 picker 分组展示。
|
|
42
|
-
* 注:hasApiKey 语义已不存在(E2 后用 hasConfiguredAuth 过滤,能进列表即已配 auth
|
|
35
|
+
* 注:hasApiKey 语义已不存在(E2 后用 hasConfiguredAuth 过滤,能进列表即已配 auth);
|
|
36
|
+
* E5:name/baseUrl/cost 死字段已删(picker 只读 id/api,生产零读者)。
|
|
43
37
|
*/
|
|
44
38
|
export interface ResolvedModelEntry {
|
|
45
39
|
provider: string;
|
|
46
40
|
id: string;
|
|
47
|
-
name: string;
|
|
48
41
|
api: string;
|
|
49
|
-
baseUrl?: string;
|
|
50
|
-
cost: ModelCost;
|
|
51
42
|
}
|
|
52
43
|
|
|
53
44
|
// ──────────────────────── listAvailableModels(W7 model picker 用) ────────────────────────
|
|
@@ -75,11 +66,7 @@ export function listAvailableModels(
|
|
|
75
66
|
entries.push({
|
|
76
67
|
provider: m.provider,
|
|
77
68
|
id: m.id,
|
|
78
|
-
name: m.name ?? m.id,
|
|
79
69
|
api: m.api,
|
|
80
|
-
...(m.baseUrl ? { baseUrl: m.baseUrl } : {}),
|
|
81
|
-
// 缺失 cost 时填零默认(picker 展示稳定,不依赖真实成本)
|
|
82
|
-
cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
83
70
|
});
|
|
84
71
|
}
|
|
85
72
|
|
package/src/commands.ts
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* /permission status 显示当前配置详情
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import
|
|
16
|
+
import { listAvailableModels } from "./classifier/model-resolver.js";
|
|
17
17
|
import { type ModelPickerContext,pickModelViaOverlay } from "./model-picker.js";
|
|
18
|
-
import { type RuleEditorContext
|
|
18
|
+
import { editRulesViaOverlay, type RuleEditorContext } from "./rule-editor.js";
|
|
19
19
|
import { applyOps } from "./rule-templates.js";
|
|
20
20
|
import {
|
|
21
21
|
isValidPermissionMode,
|
|
@@ -118,34 +118,26 @@ function modeHighlight(mode: PermissionMode): string {
|
|
|
118
118
|
|
|
119
119
|
// ──────────────────────── /permission model(W7) ────────────────────────
|
|
120
120
|
|
|
121
|
-
/** handlePermissionModelCommand 的依赖(DI 便于测试 mock)。 */
|
|
122
|
-
export interface PermissionModelCommandDeps {
|
|
123
|
-
/** 列出可用模型(按 provider 分组)。E2 后走 ctx.modelRegistry(签名带 ctx)。 */
|
|
124
|
-
listModels: (ctx: ModelPickerContext) => Map<string, ResolvedModelEntry[]>;
|
|
125
|
-
/** 保存新配置,返回 {success, error?}。 */
|
|
126
|
-
save: (config: PermissionConfig) => { success: boolean; error?: string };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
121
|
/**
|
|
130
122
|
* /permission model 命令:overlay 选择 classifier model(provider/model 或 auto)。
|
|
131
123
|
*
|
|
132
124
|
* 流程:
|
|
133
|
-
* 1.
|
|
125
|
+
* 1. listAvailableModels(ctx.modelRegistry 数据源):拿可用模型 Map。空 → 降级 notify + return。
|
|
134
126
|
* 2. pickModelViaOverlay:TUI/RPC/headless 三模式分发。
|
|
135
127
|
* 3. 写回:save(newConfig)。失败 → notify error。
|
|
136
128
|
*
|
|
137
|
-
*
|
|
138
|
-
* ctx 用 ModelPickerContext 子集(mode + ui.notify/custom/select)。
|
|
129
|
+
* save 为普通函数参数(唯一真实变化轴:保存 + footer 重绘副作用)。
|
|
130
|
+
* ctx 用 ModelPickerContext 子集(mode + modelRegistry + ui.notify/custom/select)。
|
|
139
131
|
*/
|
|
140
132
|
export async function handlePermissionModelCommand(
|
|
141
133
|
ctx: ModelPickerContext,
|
|
142
134
|
config: PermissionConfig,
|
|
143
|
-
|
|
135
|
+
save: (config: PermissionConfig) => { success: boolean; error?: string },
|
|
144
136
|
): Promise<void> {
|
|
145
137
|
const current = config.classifier.model;
|
|
146
138
|
|
|
147
|
-
// 1.
|
|
148
|
-
const models =
|
|
139
|
+
// 1. listAvailableModels(ctx.modelRegistry 数据源)
|
|
140
|
+
const models = listAvailableModels(ctx);
|
|
149
141
|
if (models.size === 0) {
|
|
150
142
|
ctx.ui.notify(
|
|
151
143
|
"[pi-permission] No available models. Run `pi auth login` or configure provider API keys, then retry.",
|
|
@@ -168,7 +160,7 @@ export async function handlePermissionModelCommand(
|
|
|
168
160
|
classifier: { ...config.classifier, model: selected },
|
|
169
161
|
userRules: config.userRules.map((r) => ({ ...r })),
|
|
170
162
|
};
|
|
171
|
-
const result =
|
|
163
|
+
const result = save(newConfig);
|
|
172
164
|
if (!result.success) {
|
|
173
165
|
ctx.ui.notify(
|
|
174
166
|
`[pi-permission] Failed to save: ${result.error ?? "unknown error"}`,
|
|
@@ -181,35 +173,24 @@ export async function handlePermissionModelCommand(
|
|
|
181
173
|
|
|
182
174
|
// ──────────────────────── /permission rule(W8) ────────────────────────
|
|
183
175
|
|
|
184
|
-
/** handlePermissionRuleCommand 的依赖(DI 便于测试 mock)。 */
|
|
185
|
-
export interface PermissionRuleCommandDeps {
|
|
186
|
-
/** 保存新配置,返回 {success, error?}。 */
|
|
187
|
-
save: (config: PermissionConfig) => { success: boolean; error?: string };
|
|
188
|
-
/** 规则编辑 overlay(注入便于测试 mock)。 */
|
|
189
|
-
editRulesViaOverlay: (
|
|
190
|
-
ctx: RuleEditorContext,
|
|
191
|
-
initialRules: readonly import("./types.js").Rule[],
|
|
192
|
-
sessionIdCounter: () => string,
|
|
193
|
-
rpcDeps?: import("./rule-editor.js").RuleEditorRpcDeps,
|
|
194
|
-
) => Promise<RuleEditorResult>;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
176
|
/**
|
|
198
177
|
* /permission rule 命令:overlay CRUD 编辑 userRules。
|
|
199
178
|
*
|
|
200
179
|
* 流程:
|
|
201
|
-
* 1. editRulesViaOverlay
|
|
180
|
+
* 1. editRulesViaOverlay(模块直调):TUI/RPC/headless 三模式分发。
|
|
202
181
|
* 2. ops 非空 → applyOps + saveConfig + notify。
|
|
203
182
|
* 3. 空 → notify no changes。
|
|
183
|
+
*
|
|
184
|
+
* save 为普通函数参数(唯一真实变化轴:保存 + footer 重绘副作用,与同步路径 onSave 同款)。
|
|
204
185
|
*/
|
|
205
186
|
export async function handlePermissionRuleCommand(
|
|
206
187
|
ctx: RuleEditorContext,
|
|
207
188
|
config: PermissionConfig,
|
|
208
189
|
sessionIdCounter: () => string,
|
|
209
|
-
|
|
190
|
+
save: (config: PermissionConfig) => { success: boolean; error?: string },
|
|
210
191
|
): Promise<void> {
|
|
211
192
|
// 1. editRulesViaOverlay
|
|
212
|
-
const ops = await
|
|
193
|
+
const ops = await editRulesViaOverlay(ctx, config.userRules, sessionIdCounter);
|
|
213
194
|
|
|
214
195
|
if (ops === undefined || ops.length === 0) {
|
|
215
196
|
ctx.ui.notify("[pi-permission] No changes applied.", "info");
|
|
@@ -224,7 +205,7 @@ export async function handlePermissionRuleCommand(
|
|
|
224
205
|
classifier: { ...config.classifier },
|
|
225
206
|
userRules: newUserRules,
|
|
226
207
|
};
|
|
227
|
-
const result =
|
|
208
|
+
const result = save(newConfig);
|
|
228
209
|
if (!result.success) {
|
|
229
210
|
ctx.ui.notify(
|
|
230
211
|
`[pi-permission] Failed to save rules: ${result.error ?? "unknown error"}`,
|
package/src/config.ts
CHANGED
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
|
|
20
|
-
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
21
20
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
22
22
|
import {
|
|
23
23
|
clearConfigCache,
|
|
24
24
|
getConfigPath as getLlmConfigPath,
|
|
25
|
+
isThinkingLevel,
|
|
25
26
|
loadConfig,
|
|
26
27
|
saveConfig as saveLlmConfig,
|
|
27
28
|
} from "@zhushanwen/pi-llm-shared";
|
|
@@ -50,26 +51,8 @@ export function getConfigPath(): string {
|
|
|
50
51
|
|
|
51
52
|
// ──────────────────────── 归一化 ────────────────────────
|
|
52
53
|
|
|
53
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
54
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const THINKING_LEVELS: ReadonlySet<string> = new Set([
|
|
58
|
-
"off",
|
|
59
|
-
"minimal",
|
|
60
|
-
"low",
|
|
61
|
-
"medium",
|
|
62
|
-
"high",
|
|
63
|
-
"xhigh",
|
|
64
|
-
"max",
|
|
65
|
-
]);
|
|
66
|
-
|
|
67
|
-
function isModelThinkingLevel(value: unknown): value is ModelThinkingLevel {
|
|
68
|
-
return typeof value === "string" && THINKING_LEVELS.has(value);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
54
|
function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
|
|
72
|
-
const record =
|
|
55
|
+
const record = isRecord(raw) ? raw : {};
|
|
73
56
|
const timeout = Number(record.timeout);
|
|
74
57
|
// C3b:classifier.model 只接受 string('auto' 或 'provider/model-id')。对象形式(如
|
|
75
58
|
// `{ "type": "available" }`)不受支持,此前会被静默忽略回落默认——现在显式 warn 消除静默。
|
|
@@ -77,7 +60,7 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
|
|
|
77
60
|
logger.warn("Ignoring invalid classifier.model (expected string 'auto' or 'provider/model-id'), using default auto");
|
|
78
61
|
}
|
|
79
62
|
// thinkingLevel 校验:合法值为 'off'|'minimal'|'low'|'medium'|'high'|'xhigh'|'max'
|
|
80
|
-
const thinkingLevel =
|
|
63
|
+
const thinkingLevel = isThinkingLevel(record.thinkingLevel)
|
|
81
64
|
? record.thinkingLevel
|
|
82
65
|
: DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
|
|
83
66
|
return {
|
|
@@ -91,7 +74,7 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
|
|
|
91
74
|
}
|
|
92
75
|
|
|
93
76
|
function normalizeRule(raw: unknown, fallbackId: string): Rule | null {
|
|
94
|
-
if (!
|
|
77
|
+
if (!isRecord(raw)) return null;
|
|
95
78
|
const tool = typeof raw.tool === "string" ? raw.tool : "*";
|
|
96
79
|
const pattern = typeof raw.pattern === "string" ? raw.pattern : "*";
|
|
97
80
|
const action = raw.action;
|
|
@@ -103,7 +86,7 @@ function normalizeRule(raw: unknown, fallbackId: string): Rule | null {
|
|
|
103
86
|
}
|
|
104
87
|
|
|
105
88
|
function normalizeConfig(raw: unknown): PermissionConfig {
|
|
106
|
-
const record =
|
|
89
|
+
const record = isRecord(raw) ? raw : {};
|
|
107
90
|
const mode = isValidPermissionMode(record.mode) ? record.mode : DEFAULT_CONFIG.mode;
|
|
108
91
|
const enabled = record.enabled !== false;
|
|
109
92
|
const classifier = normalizeClassifierConfig(record.classifier);
|
|
@@ -133,7 +116,7 @@ function ensureConfigFile(configPath: string, onWarning?: (msg: string) => void)
|
|
|
133
116
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
134
117
|
writeFileSync(configPath, createDefaultConfigContent(), { encoding: "utf-8", mode: 0o600 });
|
|
135
118
|
} catch (error) {
|
|
136
|
-
const message =
|
|
119
|
+
const message = toErrorMessage(error);
|
|
137
120
|
onWarning?.(`[pi-permission] Failed to create default config at '${configPath}': ${message}`);
|
|
138
121
|
}
|
|
139
122
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,19 +16,17 @@ import {
|
|
|
16
16
|
type ExtensionAPI,
|
|
17
17
|
type ExtensionCommandContext,
|
|
18
18
|
type ExtensionContext,
|
|
19
|
+
type ExtensionUIContext,
|
|
19
20
|
getAgentDir,
|
|
20
21
|
} from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import { oncePerProcess } from "@zhushanwen/pi-ext-guards";
|
|
22
|
+
import { oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
22
23
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
23
24
|
import { migrateLegacyConfig } from "@zhushanwen/pi-llm-shared";
|
|
24
25
|
|
|
25
26
|
const logger = getLogger("pi-permission");
|
|
26
27
|
|
|
27
|
-
import { listAvailableModels } from "./classifier/model-resolver.js";
|
|
28
28
|
import { handlePermissionCommand, handlePermissionModelCommand, handlePermissionRuleCommand } from "./commands.js";
|
|
29
29
|
import { loadAndWatchConfig, saveConfig } from "./config.js";
|
|
30
|
-
import { setDefaultListAvailableModels } from "./model-picker.js";
|
|
31
|
-
import { editRulesViaOverlay } from "./rule-editor.js";
|
|
32
30
|
import { makeNextIdCounter } from "./rule-templates.js";
|
|
33
31
|
import { checkPermission, type CheckPermissionDeps } from "./pipeline.js";
|
|
34
32
|
import { createPipelineDeps } from "./production.js";
|
|
@@ -80,6 +78,39 @@ interface ToolCallResult {
|
|
|
80
78
|
*/
|
|
81
79
|
const _UI_OPTIONS_PARAM_INDEX = 2;
|
|
82
80
|
|
|
81
|
+
// ──────────────────────── ctx.ui 适配(E8/M11 单源) ────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* E8(M11):pi ctx.ui → 本包局部 UI 上下文的统一适配(原三份闭包收敛为单源)。
|
|
85
|
+
*
|
|
86
|
+
* 三处消费:/permission rule(RuleEditorContext.ui)、/permission model
|
|
87
|
+
* (ModelPickerContext.ui)、tool_call 审批(ApprovalContext.ui)。三个目标接口的
|
|
88
|
+
* custom 泛型形态不同(前两者 factory 返回 unknown、后者返回 Component),统一靠
|
|
89
|
+
* `as Parameters<typeof ui.custom<T>>[0]` cast 吸收(T5)。
|
|
90
|
+
*
|
|
91
|
+
* select/input 的 opts 类型经 `Parameters<typeof ...>[typeof _UI_OPTIONS_PARAM_INDEX]`
|
|
92
|
+
* 索引提取(字面量索引会触发 no-magic-numbers,typeof 常量索引语义等价)。
|
|
93
|
+
* input 可选守卫展开:SDK 提供,mock/headless 可能缺失——rule-editor custom 模板
|
|
94
|
+
* 文本输入与 Reject-with-Reason(approval.ts collectRejectReason)都依赖此透传。
|
|
95
|
+
*/
|
|
96
|
+
function makeUiAdapter(ui: ExtensionUIContext) {
|
|
97
|
+
return {
|
|
98
|
+
notify: (msg: string, type?: "info" | "warning" | "error") => ui.notify(msg, type),
|
|
99
|
+
select: (title: string, options: string[], opts?: Parameters<typeof ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
|
|
100
|
+
ui.select(title, options, opts),
|
|
101
|
+
custom: <T,>(
|
|
102
|
+
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
103
|
+
options?: { overlay?: boolean },
|
|
104
|
+
): Promise<T> => ui.custom<T>(factory as Parameters<typeof ui.custom<T>>[0], options),
|
|
105
|
+
...(typeof ui.input === "function"
|
|
106
|
+
? {
|
|
107
|
+
input: (title: string, placeholder?: string, opts?: Parameters<typeof ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
|
|
108
|
+
ui.input(title, placeholder, opts),
|
|
109
|
+
}
|
|
110
|
+
: {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
83
114
|
// ──────────────────────── 扩展工厂 ────────────────────────
|
|
84
115
|
|
|
85
116
|
/**
|
|
@@ -87,10 +118,6 @@ const _UI_OPTIONS_PARAM_INDEX = 2;
|
|
|
87
118
|
* (llm-shared mtime 去重零成本,详见 config.ts「热重载契约」)。
|
|
88
119
|
*/
|
|
89
120
|
export default function permissionExtension(pi: ExtensionAPI): void {
|
|
90
|
-
// W7:注入 listAvailableModels 真实实现(model-picker.ts 默认返回空 Map)。
|
|
91
|
-
// E2 签名:(ctx) → ctx.modelRegistry.getAll() + hasConfiguredAuth 过滤。
|
|
92
|
-
setDefaultListAvailableModels((ctx) => listAvailableModels(ctx));
|
|
93
|
-
|
|
94
121
|
// ──────────────────────── 配置读取(读时刷新,回归 llm-shared 框架) ────────────────────────
|
|
95
122
|
// 不持有跨调用缓存:每次需要配置直接 loadAndWatchConfig(),llm-shared 内部 mtime+size 去重,
|
|
96
123
|
// 文件未变时零额外 IO(只 statSync)。这是 llm-shared config「热重载契约」的正确用法——
|
|
@@ -140,32 +167,14 @@ export default function permissionExtension(pi: ExtensionAPI): void {
|
|
|
140
167
|
await handlePermissionRuleCommand(
|
|
141
168
|
{
|
|
142
169
|
mode: ctx.mode,
|
|
143
|
-
ui:
|
|
144
|
-
notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
|
|
145
|
-
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
|
|
146
|
-
ctx.ui.select(title, options, opts),
|
|
147
|
-
custom: <T,>(
|
|
148
|
-
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
149
|
-
options?: { overlay?: boolean },
|
|
150
|
-
) =>
|
|
151
|
-
ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
|
|
152
|
-
// 连接 ctx.ui.input(rule-editor custom 模板文本输入用)。
|
|
153
|
-
// approval.ts 已声明可选 input(SDK 提供,mock 可能缺失)。
|
|
154
|
-
...(typeof ctx.ui.input === "function"
|
|
155
|
-
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
|
|
156
|
-
: {}),
|
|
157
|
-
},
|
|
170
|
+
ui: makeUiAdapter(ctx.ui),
|
|
158
171
|
},
|
|
159
172
|
config,
|
|
160
173
|
makeNextIdCounter(config.userRules),
|
|
161
|
-
{
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return r;
|
|
166
|
-
},
|
|
167
|
-
editRulesViaOverlay: (ctx, initialRules, sessionIdCounter, rpcDeps) =>
|
|
168
|
-
editRulesViaOverlay(ctx, initialRules, sessionIdCounter, rpcDeps),
|
|
174
|
+
(newConfig) => {
|
|
175
|
+
const r = saveConfig(newConfig);
|
|
176
|
+
if (r.success) requestFooterRender();
|
|
177
|
+
return r;
|
|
169
178
|
},
|
|
170
179
|
);
|
|
171
180
|
return;
|
|
@@ -177,29 +186,14 @@ export default function permissionExtension(pi: ExtensionAPI): void {
|
|
|
177
186
|
mode: ctx.mode,
|
|
178
187
|
// E2:model picker 数据源(listAvailableModels 走 modelRegistry)
|
|
179
188
|
modelRegistry: ctx.modelRegistry,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
|
|
183
|
-
ctx.ui.select(title, options, opts),
|
|
184
|
-
custom: <T,>(
|
|
185
|
-
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
186
|
-
options?: { overlay?: boolean },
|
|
187
|
-
) =>
|
|
188
|
-
ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
|
|
189
|
-
// 连接 ctx.ui.input(与 rule handler 一致;model picker 当前不用,但保持 ctx 对称)。
|
|
190
|
-
...(typeof ctx.ui.input === "function"
|
|
191
|
-
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
|
|
192
|
-
: {}),
|
|
193
|
-
},
|
|
189
|
+
// input 透传保持 ctx 对称(model picker 当前不用,rule handler 用)
|
|
190
|
+
ui: makeUiAdapter(ctx.ui),
|
|
194
191
|
},
|
|
195
192
|
config,
|
|
196
|
-
{
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (r.success) requestFooterRender();
|
|
201
|
-
return r;
|
|
202
|
-
},
|
|
193
|
+
(newConfig) => {
|
|
194
|
+
const r = saveConfig(newConfig);
|
|
195
|
+
if (r.success) requestFooterRender();
|
|
196
|
+
return r;
|
|
203
197
|
},
|
|
204
198
|
);
|
|
205
199
|
return;
|
|
@@ -320,20 +314,7 @@ async function processToolCall(
|
|
|
320
314
|
// 装配 deps(每次 tool_call 重新装配,捕获当前 ctx.mode/ui;classifier 走 ctx.modelRegistry)
|
|
321
315
|
const approvalCtx = {
|
|
322
316
|
mode: ctx.mode,
|
|
323
|
-
ui:
|
|
324
|
-
notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
|
|
325
|
-
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.select(title, options, opts),
|
|
326
|
-
custom: <T,>(
|
|
327
|
-
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
328
|
-
options?: { overlay?: boolean },
|
|
329
|
-
) =>
|
|
330
|
-
ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
|
|
331
|
-
// W6 T9 G3:Reject-with-Reason。ctx.ui.input 存在则透传(采集真实拒绝理由)。
|
|
332
|
-
// approval.ts 的 collectRejectReason 会用 typeof 判断是否可用,不可用则 fallback。
|
|
333
|
-
...(typeof ctx.ui.input === "function"
|
|
334
|
-
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
|
|
335
|
-
: {}),
|
|
336
|
-
},
|
|
317
|
+
ui: makeUiAdapter(ctx.ui),
|
|
337
318
|
};
|
|
338
319
|
const deps: CheckPermissionDeps = createPipelineDeps(approvalCtx, ctx);
|
|
339
320
|
|
|
@@ -360,7 +341,7 @@ async function processToolCall(
|
|
|
360
341
|
};
|
|
361
342
|
} catch (error) {
|
|
362
343
|
// fail-closed:异常 → block + reason(绝不放行)
|
|
363
|
-
const msg =
|
|
344
|
+
const msg = toErrorMessage(error);
|
|
364
345
|
logger.warn("tool_call handler exception", { toolName, error: msg });
|
|
365
346
|
return {
|
|
366
347
|
block: true,
|