@zhushanwen/pi-permission 1.4.9 → 1.6.0
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 +11 -11
- package/src/__tests__/config.test.ts +2 -2
- package/src/__tests__/pipeline.test.ts +20 -9
- package/src/__tests__/types.test.ts +1 -1
- package/src/classifier/__tests__/classifier.test.ts +23 -0
- package/src/classifier/classifier.ts +17 -4
- package/src/config.ts +3 -5
- package/src/pipeline.ts +9 -14
- package/src/types.ts +7 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
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",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"classifier": {
|
|
16
16
|
"enabled": true,
|
|
17
17
|
"model": "auto",
|
|
18
|
-
"timeout":
|
|
18
|
+
"timeout": 0,
|
|
19
19
|
"autoApproveLowRisk": true,
|
|
20
20
|
"autoDenyHighRisk": true,
|
|
21
21
|
"thinkingLevel": "off"
|
|
@@ -50,14 +50,14 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"tree-sitter-bash": "^0.25.1",
|
|
52
52
|
"web-tree-sitter": "^0.26.11",
|
|
53
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
54
|
-
"@zhushanwen/pi-extension-logger": "0.6.
|
|
55
|
-
"@zhushanwen/pi-llm-shared": "0.
|
|
53
|
+
"@zhushanwen/pi-ext-guards": "0.4.3",
|
|
54
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
55
|
+
"@zhushanwen/pi-llm-shared": "0.11.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@earendil-works/pi-ai": "^0.
|
|
59
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
60
|
-
"@earendil-works/pi-tui": "^0.
|
|
58
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
59
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
60
|
+
"@earendil-works/pi-tui": "^1.0.0"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|
|
63
63
|
"@earendil-works/pi-ai": {
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
|
-
"@earendil-works/pi-ai": "^0.
|
|
75
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
76
|
-
"@earendil-works/pi-tui": "^0.
|
|
74
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
75
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
76
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
77
77
|
"@vitest/coverage-v8": "^4.1.9",
|
|
78
78
|
"vitest": "^4.1.8"
|
|
79
79
|
},
|
|
@@ -54,7 +54,7 @@ describe("WT1: 默认配置生成(首次无配置文件)", () => {
|
|
|
54
54
|
expect(config.enabled).toBe(true);
|
|
55
55
|
expect(config.classifier.enabled).toBe(true);
|
|
56
56
|
expect(config.classifier.model).toBe("auto");
|
|
57
|
-
expect(config.classifier.timeout).toBe(
|
|
57
|
+
expect(config.classifier.timeout).toBe(0);
|
|
58
58
|
expect(config.classifier.autoApproveLowRisk).toBe(true);
|
|
59
59
|
expect(config.classifier.autoDenyHighRisk).toBe(true);
|
|
60
60
|
expect(config.userRules).toEqual([]);
|
|
@@ -130,7 +130,7 @@ describe("WT3: 配置解析容错(malformed JSON)", () => {
|
|
|
130
130
|
const config = loadAndWatchConfig();
|
|
131
131
|
expect(config.mode).toBe("strict");
|
|
132
132
|
expect(config.classifier.model).toBe("auto"); // 默认
|
|
133
|
-
expect(config.classifier.timeout).toBe(
|
|
133
|
+
expect(config.classifier.timeout).toBe(0); // 默认
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
it("TC6 (C3b): classifier.model 传对象形式 → console.warn + 回落默认 auto", () => {
|
|
@@ -844,16 +844,19 @@ describe("m3: bash 工具收到非字符串 command 时 logger.warn(不静默
|
|
|
844
844
|
});
|
|
845
845
|
});
|
|
846
846
|
|
|
847
|
-
// ──────────────────────── M3:
|
|
847
|
+
// ──────────────────────── M3: AI-ask 分支等待用户决策(无包内超时) ────────────────────────
|
|
848
848
|
|
|
849
|
-
describe("M3: runLayer3WithRacing AI-ask
|
|
850
|
-
it("AI ask
|
|
851
|
-
// 用 fake timer 模拟 5 分钟流逝,避免真实等待
|
|
849
|
+
describe("M3: runLayer3WithRacing AI-ask 分支(ADR-0122:无包内超时兜底)", () => {
|
|
850
|
+
it("AI ask → 无超时兜底等待;远超原 5min 后用户决策照常生效", async () => {
|
|
852
851
|
vi.useFakeTimers();
|
|
853
852
|
try {
|
|
853
|
+
let resolveApproval: (d: UserDecision) => void = () => {};
|
|
854
854
|
const deps = makeDeps({
|
|
855
855
|
classify: () => Promise.resolve(fallbackClassifier()), // outcome=ask
|
|
856
|
-
approve: () =>
|
|
856
|
+
approve: () =>
|
|
857
|
+
new Promise<UserDecision>((resolve) => {
|
|
858
|
+
resolveApproval = resolve;
|
|
859
|
+
}),
|
|
857
860
|
});
|
|
858
861
|
const { runLayer3WithRacing } = await import("../pipeline.js");
|
|
859
862
|
const decisionPromise = runLayer3WithRacing(
|
|
@@ -862,11 +865,19 @@ describe("M3: runLayer3WithRacing AI-ask 分支超时兜底(防永久挂起)
|
|
|
862
865
|
DEFAULT_CFG,
|
|
863
866
|
undefined,
|
|
864
867
|
);
|
|
865
|
-
// 推进
|
|
866
|
-
await vi.advanceTimersByTimeAsync(
|
|
868
|
+
// 推进 10 分钟(远超原 APPROVAL_TIMEOUT_MS=300_000):决策未出(无超时兜底,等待用户是交互语义)
|
|
869
|
+
await vi.advanceTimersByTimeAsync(600_000);
|
|
870
|
+
let settled = false;
|
|
871
|
+
void decisionPromise.then(() => {
|
|
872
|
+
settled = true;
|
|
873
|
+
});
|
|
874
|
+
await Promise.resolve();
|
|
875
|
+
expect(settled).toBe(false);
|
|
876
|
+
// 用户最终决策 → 按决策返回(用户晚到不被超时截杀)
|
|
877
|
+
resolveApproval({ approved: true, reason: "late approval" });
|
|
867
878
|
const decision = await decisionPromise;
|
|
868
|
-
expect(decision.action).toBe("
|
|
869
|
-
expect(decision.
|
|
879
|
+
expect(decision.action).toBe("allow");
|
|
880
|
+
expect(decision.source).toBe("user");
|
|
870
881
|
} finally {
|
|
871
882
|
vi.useRealTimers();
|
|
872
883
|
}
|
|
@@ -51,7 +51,7 @@ describe("WT6: PermissionMode 枚举完整性", () => {
|
|
|
51
51
|
expect(DEFAULT_CONFIG.enabled).toBe(true);
|
|
52
52
|
expect(DEFAULT_CONFIG.classifier.enabled).toBe(true);
|
|
53
53
|
expect(DEFAULT_CONFIG.classifier.model).toBe("auto");
|
|
54
|
-
expect(DEFAULT_CONFIG.classifier.timeout).toBe(
|
|
54
|
+
expect(DEFAULT_CONFIG.classifier.timeout).toBe(0);
|
|
55
55
|
expect(DEFAULT_CONFIG.classifier.autoApproveLowRisk).toBe(true);
|
|
56
56
|
expect(DEFAULT_CONFIG.classifier.autoDenyHighRisk).toBe(true);
|
|
57
57
|
expect(DEFAULT_CONFIG.userRules).toEqual([]);
|
|
@@ -282,4 +282,27 @@ describe("CT5: callLLM opts 构造(model/messages/timeoutMs/signal)", () =>
|
|
|
282
282
|
const opts = getLastOpts();
|
|
283
283
|
expect(opts?.signal).toBe(ac.signal);
|
|
284
284
|
});
|
|
285
|
+
|
|
286
|
+
it("配置 thinkingLevel 不被模型支持 → warn 留痕 + reasoning 不传(不静默降级)", async () => {
|
|
287
|
+
// FIXED_MODEL_OBJ reasoning:false → supportedLevels = ["off"],配置 "high" 判定落空;
|
|
288
|
+
// changeset pi-permission-thinking-level-datadriven 承诺 warn 留痕(同族 rename-session 对齐)
|
|
289
|
+
const onLog = vi.fn();
|
|
290
|
+
const { spy, getLastOpts } = capturingCallLLM(okResult('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'));
|
|
291
|
+
const classifier = createClassifier(makeDeps({ callLLM: spy, onLog }));
|
|
292
|
+
const r = await classifier.classifyRisk(CTX, { ...CONFIG, thinkingLevel: "high" });
|
|
293
|
+
expect(r.outcome).toBe("allow");
|
|
294
|
+
expect(onLog).toHaveBeenCalledWith(
|
|
295
|
+
expect.stringContaining('configured thinkingLevel "high" is not supported by test-co/test-model; calling without a thinking level'),
|
|
296
|
+
);
|
|
297
|
+
expect(getLastOpts()?.reasoning).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("配置 thinkingLevel 被模型支持 → 命中透传,无 warn", async () => {
|
|
301
|
+
const onLog = vi.fn();
|
|
302
|
+
const { spy, getLastOpts } = capturingCallLLM(okResult('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'));
|
|
303
|
+
const classifier = createClassifier(makeDeps({ callLLM: spy, onLog }));
|
|
304
|
+
await classifier.classifyRisk(CTX, { ...CONFIG, thinkingLevel: "off" });
|
|
305
|
+
expect(onLog).not.toHaveBeenCalledWith(expect.stringContaining("not supported"));
|
|
306
|
+
expect(getLastOpts()?.reasoning).toBe("off");
|
|
307
|
+
});
|
|
285
308
|
});
|
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
* - G3 修正(由 callLLM 承接):completeSimple 的 EventStream.result() 只 resolve 不 reject,
|
|
14
14
|
* error/aborted 也 resolve(带 stopReason)。callLLM 已把 error/aborted 归一为 ok:false +
|
|
15
15
|
* stopReason 独立透传;classifier 消费透传字段保留日志区分(abort 与 error 分开记)
|
|
16
|
-
* -
|
|
16
|
+
* - 外层超时/中止兜底:仅在用户显式配置 timeout > 0 时武装(默认 0 = 不限时,ADR-0122:
|
|
17
|
+
* 无包内挂死兜底);abort 竞速为事件驱动恒在。武装时防御 provider 不支持 timeoutMs
|
|
18
|
+
* 时 result() 永挂
|
|
17
19
|
* - fail-closed:timeout / 抛错 / 解析失败 / 无可用模型 → 一律 ask
|
|
18
20
|
*/
|
|
19
21
|
|
|
22
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
20
23
|
import type { Api, Message, Model } from "@earendil-works/pi-ai";
|
|
21
24
|
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
22
25
|
import type { CallLLMOptions, CallLLMResult } from "@zhushanwen/pi-llm-shared";
|
|
@@ -133,15 +136,25 @@ function buildCallOptions(
|
|
|
133
136
|
config: ClassifierConfig,
|
|
134
137
|
timeoutMs: number | undefined,
|
|
135
138
|
signal: AbortSignal | undefined,
|
|
139
|
+
onLog?: (msg: string) => void,
|
|
136
140
|
): CallLLMOptions {
|
|
141
|
+
// 档位按「该模型自己的 supportedLevels」判定(pi-ai 数据驱动,本层不自持词表);
|
|
142
|
+
// 不支持则按「不传档位」处理,不静默换成别的档(clamp),并 warn 留痕
|
|
143
|
+
// (changeset pi-permission-thinking-level-datadriven 的承诺,与 rename-session 同族对齐)。
|
|
144
|
+
// llm-shared 会把 "off" 映射为不传。
|
|
145
|
+
const reasoning = getSupportedThinkingLevels(model).find((l) => l === config.thinkingLevel);
|
|
146
|
+
if (config.thinkingLevel !== undefined && config.thinkingLevel !== "" && reasoning === undefined) {
|
|
147
|
+
onLog?.(
|
|
148
|
+
`[pi-permission] classifier: configured thinkingLevel "${config.thinkingLevel}" is not supported by ${model.provider}/${model.id}; calling without a thinking level`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
137
151
|
return {
|
|
138
152
|
model,
|
|
139
153
|
systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
|
|
140
154
|
messages: buildMessages(ctx),
|
|
141
155
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
142
156
|
...(signal !== undefined ? { signal } : {}),
|
|
143
|
-
|
|
144
|
-
reasoning: config.thinkingLevel,
|
|
157
|
+
reasoning,
|
|
145
158
|
};
|
|
146
159
|
}
|
|
147
160
|
|
|
@@ -223,7 +236,7 @@ export function createClassifier(deps: ClassifierDeps): {
|
|
|
223
236
|
|
|
224
237
|
// 2. 构造 CallLLMOptions
|
|
225
238
|
const timeoutMs = config.timeout > 0 ? config.timeout * MILLIS_PER_SECOND : undefined;
|
|
226
|
-
const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal));
|
|
239
|
+
const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal, onLog));
|
|
227
240
|
|
|
228
241
|
// 3. 等待 callLLM + 外层超时/中止兜底(provider 不支持 timeoutMs/signal 时防永挂)
|
|
229
242
|
let settled: SettledRace;
|
package/src/config.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
|
22
22
|
import {
|
|
23
23
|
clearConfigCache,
|
|
24
24
|
getConfigPath as getLlmConfigPath,
|
|
25
|
-
|
|
25
|
+
normalizeThinkingLevel,
|
|
26
26
|
loadConfig,
|
|
27
27
|
saveConfig as saveLlmConfig,
|
|
28
28
|
} from "@zhushanwen/pi-llm-shared";
|
|
@@ -59,10 +59,8 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
|
|
|
59
59
|
if (record.model !== undefined && !(typeof record.model === "string" && record.model.length > 0)) {
|
|
60
60
|
logger.warn("Ignoring invalid classifier.model (expected string 'auto' or 'provider/model-id'), using default auto");
|
|
61
61
|
}
|
|
62
|
-
// thinkingLevel
|
|
63
|
-
const thinkingLevel =
|
|
64
|
-
? record.thinkingLevel
|
|
65
|
-
: DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
|
|
62
|
+
// thinkingLevel:非空字符串即采信(合法档位由写入侧 UI 与 pi 判定;本层不再自持词表)
|
|
63
|
+
const thinkingLevel = normalizeThinkingLevel(record.thinkingLevel) ?? DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
|
|
66
64
|
return {
|
|
67
65
|
enabled: record.enabled !== false,
|
|
68
66
|
model: typeof record.model === "string" && record.model.length > 0 ? record.model : DEFAULT_CLASSIFIER_CONFIG.model,
|
package/src/pipeline.ts
CHANGED
|
@@ -301,24 +301,19 @@ function settleAiWin(
|
|
|
301
301
|
|
|
302
302
|
/**
|
|
303
303
|
* AI ask → 转 human(等用户最终决策,不关闭对话框)。
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
304
|
+
*
|
|
305
|
+
* 无超时(ADR-0122):等待用户审批是交互语义(审批对话框无上界等待 = 权限系统
|
|
306
|
+
* fail-closed 本职:用户不动 = 不放行也不拒绝),非挂死保护。promise 落定由结构
|
|
307
|
+
* 保证——pi 实装 showExtensionCustom(1.0.0 dist/modes/interactive/interactive-mode.js)
|
|
308
|
+
* 中 close 即 resolve 函数、调用必 resolve;abort 路径经 comp.cancel→done / rpc select signal 短路。
|
|
309
|
+
* 原 M3 的 5min APPROVAL_TIMEOUT_MS 兜底已删:其登记的根因(同 tick 操作致
|
|
310
|
+
* _resolved 置位但 done 未调 → promise 永不 resolve)经现行代码 + pi 实装核实
|
|
311
|
+
* 不存在——ApprovalComponent 的 approve/deny/cancel 三个 settle 点均同步紧跟 done。
|
|
308
312
|
*/
|
|
309
313
|
async function awaitUserAfterAiAsk(
|
|
310
314
|
realUserPromise: Promise<UserDecision>,
|
|
311
315
|
): Promise<PermissionDecision> {
|
|
312
|
-
const
|
|
313
|
-
const userFinal = await Promise.race<UserDecision>([
|
|
314
|
-
realUserPromise,
|
|
315
|
-
new Promise<UserDecision>((resolve) =>
|
|
316
|
-
setTimeout(
|
|
317
|
-
() => resolve({ approved: false, reason: "approval dialog timeout (fail-closed)" }),
|
|
318
|
-
APPROVAL_TIMEOUT_MS,
|
|
319
|
-
),
|
|
320
|
-
),
|
|
321
|
-
]);
|
|
316
|
+
const userFinal = await realUserPromise;
|
|
322
317
|
const action: PermissionAction = userFinal.approved ? "allow" : "deny";
|
|
323
318
|
return {
|
|
324
319
|
action,
|
package/src/types.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* 对应 slice plan 的 DM1-DM5 数据模型。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
9
8
|
|
|
10
9
|
// ──────────────────────── DM1: PermissionMode ────────────────────────
|
|
11
10
|
|
|
@@ -101,18 +100,21 @@ export interface ClassifierConfig {
|
|
|
101
100
|
enabled: boolean;
|
|
102
101
|
/** 模型:'auto'(取 modelRegistry.getAvailable() 首个可用)或精确 'provider/model-id'(如 'zhipu/glm-4-flash') */
|
|
103
102
|
model: string;
|
|
104
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* 超时秒数(仅用户显式配置时生效的 C2 业务超时;0 = 不限时)。
|
|
105
|
+
* 包内默认 0(ADR-0122:无包内挂死兜底,LLM 挂死由上层失败直报,不设时间窗猜测)。
|
|
106
|
+
*/
|
|
105
107
|
timeout: number;
|
|
106
108
|
/** 低风险是否自动放行 */
|
|
107
109
|
autoApproveLowRisk: boolean;
|
|
108
110
|
/** 高风险是否强制 deny(不问人:high+allow 时直接拒绝) */
|
|
109
111
|
autoDenyHighRisk: boolean;
|
|
110
112
|
/**
|
|
111
|
-
* 标题生成 LLM 的 thinking
|
|
113
|
+
* 标题生成 LLM 的 thinking 级别(字符串透传;合法档位按模型 supportedLevels 在调用点判定)。
|
|
112
114
|
* 默认 "off":不传 pi-ai reasoning(provider 默认行为);
|
|
113
115
|
* "minimal"~"max" 透传给 SimpleStreamOptions.reasoning(provider 不支持时静默忽略)。
|
|
114
116
|
*/
|
|
115
|
-
thinkingLevel:
|
|
117
|
+
thinkingLevel: string;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
// ──────────────────────── DM5: PermissionConfig ────────────────────────
|
|
@@ -134,7 +136,7 @@ export interface PermissionConfig {
|
|
|
134
136
|
export const DEFAULT_CLASSIFIER_CONFIG: ClassifierConfig = {
|
|
135
137
|
enabled: true,
|
|
136
138
|
model: "auto",
|
|
137
|
-
timeout:
|
|
139
|
+
timeout: 0,
|
|
138
140
|
autoApproveLowRisk: true,
|
|
139
141
|
autoDenyHighRisk: true,
|
|
140
142
|
thinkingLevel: "off",
|