@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "1.4.9",
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": 90,
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.2",
54
- "@zhushanwen/pi-extension-logger": "0.6.1",
55
- "@zhushanwen/pi-llm-shared": "0.10.1"
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.84.4",
59
- "@earendil-works/pi-coding-agent": "^0.84.4",
60
- "@earendil-works/pi-tui": "^0.84.4"
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.84.4",
75
- "@earendil-works/pi-coding-agent": "^0.84.4",
76
- "@earendil-works/pi-tui": "^0.84.4",
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(90);
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(90); // 默认
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: realUserPromise 超时兜底 ────────────────────────
847
+ // ──────────────────────── M3: AI-ask 分支等待用户决策(无包内超时) ────────────────────────
848
848
 
849
- describe("M3: runLayer3WithRacing AI-ask 分支超时兜底(防永久挂起)", () => {
850
- it("AI ask + requestUserApproval 永挂 → 5 分钟超时 fail-closed deny(不永久阻塞)", async () => {
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: () => new Promise<UserDecision>(() => undefined), // 永不 resolve(模拟挂起)
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
- // 推进 5 分钟(超过 APPROVAL_TIMEOUT_MS=300_000)
866
- await vi.advanceTimersByTimeAsync(300_001);
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("deny"); // fail-closed
869
- expect(decision.reason).toContain("timeout");
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(90);
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
- * - 外层超时/中止兜底保留:防御 provider 不支持 timeoutMs 时 result() 永挂
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
- // thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
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
- isThinkingLevel,
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 校验:合法值为 'off'|'minimal'|'low'|'medium'|'high'|'xhigh'|'max'
63
- const thinkingLevel = isThinkingLevel(record.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
- * M3:超时兜底防止永久挂起。若 AI 返回 ask 后用户恰好与 AI 在同一 tick 操作,
305
- * comp.cancel() 因 _resolved 守卫不调 done → requestUserApproval promise 永不
306
- * resolve → realUserPromise pending → 永久挂起(G5 串行化放大为全链卡死)。
307
- * 5 分钟超时后 fail-closed 拒绝(不静默放行)。
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 APPROVAL_TIMEOUT_MS = 300_000;
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 级别(pi 的 ModelThinkingLevel,THINKING_ORDER SSOT)。
113
+ * 标题生成 LLM 的 thinking 级别(字符串透传;合法档位按模型 supportedLevels 在调用点判定)。
112
114
  * 默认 "off":不传 pi-ai reasoning(provider 默认行为);
113
115
  * "minimal"~"max" 透传给 SimpleStreamOptions.reasoning(provider 不支持时静默忽略)。
114
116
  */
115
- thinkingLevel: ModelThinkingLevel;
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: 90,
139
+ timeout: 0,
138
140
  autoApproveLowRisk: true,
139
141
  autoDenyHighRisk: true,
140
142
  thinkingLevel: "off",