@zhushanwen/pi-permission 1.3.2 → 1.3.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 +28 -9
- package/src/__tests__/config.test.ts +44 -42
- package/src/__tests__/footer-provider.test.ts +14 -4
- package/src/__tests__/pipeline.test.ts +15 -7
- package/src/__tests__/production.test.ts +14 -8
- package/src/__tests__/startup-config-declaration.test.ts +34 -0
- package/src/ast/analyzer.ts +9 -3
- package/src/ast/loader.ts +5 -2
- package/src/commands.ts +4 -1
- package/src/config.ts +10 -4
- package/src/footer-provider.ts +7 -3
- package/src/index.ts +21 -11
- package/src/model-picker.ts +4 -4
- package/src/pipeline.ts +6 -3
- package/src/production.ts +4 -1
- package/src/rule-editor-component.ts +23 -13
- package/src/rules/builtins.ts +4 -1
package/package.json
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
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",
|
|
7
7
|
"xyz-agent": {
|
|
8
|
-
"role": "universal"
|
|
8
|
+
"role": "universal",
|
|
9
|
+
"startupConfig": [
|
|
10
|
+
{
|
|
11
|
+
"path": "config/permission-ext-config.json",
|
|
12
|
+
"content": {
|
|
13
|
+
"mode": "yolo",
|
|
14
|
+
"enabled": true,
|
|
15
|
+
"classifier": {
|
|
16
|
+
"enabled": true,
|
|
17
|
+
"model": "auto",
|
|
18
|
+
"timeout": 90,
|
|
19
|
+
"autoApproveLowRisk": true,
|
|
20
|
+
"autoDenyHighRisk": true,
|
|
21
|
+
"thinkingLevel": "off"
|
|
22
|
+
},
|
|
23
|
+
"userRules": []
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
9
27
|
},
|
|
10
28
|
"pi": {
|
|
11
29
|
"extensions": [
|
|
@@ -32,12 +50,13 @@
|
|
|
32
50
|
"dependencies": {
|
|
33
51
|
"tree-sitter-bash": "^0.25.1",
|
|
34
52
|
"web-tree-sitter": "^0.26.11",
|
|
35
|
-
"@zhushanwen/pi-
|
|
53
|
+
"@zhushanwen/pi-extension-logger": "0.3.1",
|
|
54
|
+
"@zhushanwen/pi-llm-shared": "0.4.2"
|
|
36
55
|
},
|
|
37
56
|
"peerDependencies": {
|
|
38
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
39
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
40
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
57
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
58
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
59
|
+
"@earendil-works/pi-tui": "^0.84.4"
|
|
41
60
|
},
|
|
42
61
|
"peerDependenciesMeta": {
|
|
43
62
|
"@earendil-works/pi-ai": {
|
|
@@ -51,9 +70,9 @@
|
|
|
51
70
|
}
|
|
52
71
|
},
|
|
53
72
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
56
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
73
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
74
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
75
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
57
76
|
"@vitest/coverage-v8": "^4.1.9",
|
|
58
77
|
"vitest": "^4.1.8"
|
|
59
78
|
},
|
|
@@ -9,6 +9,16 @@ import { tmpdir } from "node:os";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
11
|
|
|
12
|
+
// Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
|
|
13
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
14
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
15
|
+
}));
|
|
16
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
17
|
+
getLogger: () => loggerMock,
|
|
18
|
+
createLogger: () => loggerMock,
|
|
19
|
+
setPiHandle: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
12
22
|
import { DEFAULT_CONFIG } from "../types.js";
|
|
13
23
|
import {
|
|
14
24
|
clearConfigCache,
|
|
@@ -127,42 +137,36 @@ describe("WT3: 配置解析容错(malformed JSON)", () => {
|
|
|
127
137
|
// 用户照旧设计文档配对象形式 selector(不受支持)
|
|
128
138
|
writeFileSync(configPath, JSON.stringify({ classifier: { model: { type: "available" } } }), "utf-8");
|
|
129
139
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
expect
|
|
137
|
-
|
|
138
|
-
warnSpy.mockRestore();
|
|
139
|
-
}
|
|
140
|
+
loggerMock.warn.mockClear();
|
|
141
|
+
const config = loadAndWatchConfig();
|
|
142
|
+
// 回落默认 auto(不再静默)
|
|
143
|
+
expect(config.classifier.model).toBe("auto");
|
|
144
|
+
// warn 提示忽略无效 classifier.model
|
|
145
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
146
|
+
expect.stringContaining("Ignoring invalid classifier.model"),
|
|
147
|
+
);
|
|
140
148
|
});
|
|
141
149
|
|
|
142
|
-
it("classifier.model 传数字 →
|
|
150
|
+
it("classifier.model 传数字 → logger.warn + 回落默认 auto", () => {
|
|
143
151
|
writeFileSync(configPath, JSON.stringify({ classifier: { model: 42 } }), "utf-8");
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
expect
|
|
150
|
-
|
|
151
|
-
warnSpy.mockRestore();
|
|
152
|
-
}
|
|
153
|
+
loggerMock.warn.mockClear();
|
|
154
|
+
const config = loadAndWatchConfig();
|
|
155
|
+
expect(config.classifier.model).toBe("auto");
|
|
156
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
157
|
+
expect.stringContaining("Ignoring invalid classifier.model"),
|
|
158
|
+
);
|
|
153
159
|
});
|
|
154
160
|
|
|
155
161
|
it("classifier.model 合法 string('auto' / 'provider/model-id')→ 不 warn", () => {
|
|
156
162
|
writeFileSync(configPath, JSON.stringify({ classifier: { model: "zhipu/glm-4-flash" } }), "utf-8");
|
|
157
163
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
expect
|
|
163
|
-
|
|
164
|
-
warnSpy.mockRestore();
|
|
165
|
-
}
|
|
164
|
+
loggerMock.warn.mockClear();
|
|
165
|
+
const config = loadAndWatchConfig();
|
|
166
|
+
expect(config.classifier.model).toBe("zhipu/glm-4-flash");
|
|
167
|
+
expect(loggerMock.warn).not.toHaveBeenCalledWith(
|
|
168
|
+
expect.stringContaining("Ignoring invalid classifier.model"),
|
|
169
|
+
);
|
|
166
170
|
});
|
|
167
171
|
|
|
168
172
|
it("userRules 含非法条目时过滤掉", () => {
|
|
@@ -275,23 +279,21 @@ describe("[MIGRATION] legacy config 降级告警(ensureConfigFile 兜底)",
|
|
|
275
279
|
const legacyPath = join(tempDir, "permission-config.json");
|
|
276
280
|
writeFileSync(legacyPath, JSON.stringify({ mode: "strict" }), "utf-8");
|
|
277
281
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
expect
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
282
|
+
loggerMock.warn.mockClear();
|
|
283
|
+
loadAndWatchConfig();
|
|
284
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
285
|
+
expect.stringContaining("Legacy config detected"),
|
|
286
|
+
);
|
|
287
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
288
|
+
expect.stringContaining("downgrade"),
|
|
289
|
+
);
|
|
286
290
|
});
|
|
287
291
|
|
|
288
292
|
it("旧路径不存在 → 不触发 legacy 告警", () => {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
expect
|
|
293
|
-
|
|
294
|
-
warnSpy.mockRestore();
|
|
295
|
-
}
|
|
293
|
+
loggerMock.warn.mockClear();
|
|
294
|
+
loadAndWatchConfig();
|
|
295
|
+
expect(loggerMock.warn).not.toHaveBeenCalledWith(
|
|
296
|
+
expect.stringContaining("Legacy config detected"),
|
|
297
|
+
);
|
|
296
298
|
});
|
|
297
299
|
});
|
|
@@ -16,6 +16,16 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
18
|
|
|
19
|
+
// Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
|
|
20
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
21
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
24
|
+
getLogger: () => loggerMock,
|
|
25
|
+
createLogger: () => loggerMock,
|
|
26
|
+
setPiHandle: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
19
29
|
import {
|
|
20
30
|
FOOTER_HANDSHAKE_KEY,
|
|
21
31
|
type FooterLineRenderer,
|
|
@@ -211,8 +221,8 @@ describe("TC6/TC7:requestFooterRender", () => {
|
|
|
211
221
|
// ──────────────────────── TC8:version mismatch → 重建 slot ────────────────────────
|
|
212
222
|
|
|
213
223
|
describe("TC8:version mismatch → readSlot 丢弃 + ensureSlot 重建", () => {
|
|
214
|
-
it("slot.version !== 1 → 注册时建新 slot(丢弃旧的),并
|
|
215
|
-
|
|
224
|
+
it("slot.version !== 1 → 注册时建新 slot(丢弃旧的),并 logger.warn", () => {
|
|
225
|
+
loggerMock.warn.mockClear();
|
|
216
226
|
// 污染槽位:version=99
|
|
217
227
|
Reflect.set(globalThis, FOOTER_HANDSHAKE_KEY, {
|
|
218
228
|
version: 99,
|
|
@@ -222,10 +232,10 @@ describe("TC8:version mismatch → readSlot 丢弃 + ensureSlot 重建", () =>
|
|
|
222
232
|
const slot = getRawSlot() as { version: number; pending: unknown[] };
|
|
223
233
|
expect(slot.version).toBe(1); // 重建为 version=1
|
|
224
234
|
expect(slot.pending).toHaveLength(1);
|
|
225
|
-
expect(
|
|
235
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
226
236
|
expect.stringContaining("version mismatch"),
|
|
237
|
+
expect.any(Object),
|
|
227
238
|
);
|
|
228
|
-
warnSpy.mockRestore();
|
|
229
239
|
});
|
|
230
240
|
|
|
231
241
|
it("pending 非 Array → 视为无 slot,重建", () => {
|
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it, vi } from "vitest";
|
|
9
9
|
|
|
10
|
+
// Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
|
|
11
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
12
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
15
|
+
getLogger: () => loggerMock,
|
|
16
|
+
createLogger: () => loggerMock,
|
|
17
|
+
setPiHandle: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
10
20
|
import {
|
|
11
21
|
applyAutoApproveOverrides,
|
|
12
22
|
buildApprovalRequest,
|
|
@@ -708,9 +718,9 @@ describe("M5b: runLayer3WithRacing headless 模式(纯等 AI,不启动 user
|
|
|
708
718
|
|
|
709
719
|
// ──────────────────────── m3: extractCommand 非字符串 bash command 告警 ────────────────────────
|
|
710
720
|
|
|
711
|
-
describe("m3: bash 工具收到非字符串 command 时
|
|
721
|
+
describe("m3: bash 工具收到非字符串 command 时 logger.warn(不静默)", () => {
|
|
712
722
|
it("command 为数字 → warn 被调用 + fail-closed(不 throw,送下游)", async () => {
|
|
713
|
-
|
|
723
|
+
loggerMock.warn.mockClear();
|
|
714
724
|
const deps = makeDeps({
|
|
715
725
|
classify: () => Promise.resolve(allowClassifier("low")),
|
|
716
726
|
approve: () => new Promise<UserDecision>(() => undefined),
|
|
@@ -718,21 +728,19 @@ describe("m3: bash 工具收到非字符串 command 时 console.warn(不静默
|
|
|
718
728
|
// command 为数字(异常输入)
|
|
719
729
|
const decision = await checkPermission("bash", { command: 123 }, "auto", DEFAULT_CFG, [], deps, ctxBase);
|
|
720
730
|
// warn 被调用(记录异常)
|
|
721
|
-
expect(
|
|
722
|
-
warnSpy.mockRestore();
|
|
731
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("non-string command"), expect.any(Object));
|
|
723
732
|
// fail-closed:不 throw,正常返回决策(AI allow low → allow)
|
|
724
733
|
expect(decision.action).toBe("allow");
|
|
725
734
|
});
|
|
726
735
|
|
|
727
736
|
it("command 为合法字符串 → 不 warn", async () => {
|
|
728
|
-
|
|
737
|
+
loggerMock.warn.mockClear();
|
|
729
738
|
const deps = makeDeps({
|
|
730
739
|
analyze: () => Promise.resolve(cleanAnalysis([["ls"]])),
|
|
731
740
|
matchArgv: () => ({ action: "allow", matchedRule: undefined }),
|
|
732
741
|
});
|
|
733
742
|
await checkPermission("bash", { command: "ls" }, "approve", DEFAULT_CFG, [], deps, ctxBase);
|
|
734
|
-
expect(
|
|
735
|
-
warnSpy.mockRestore();
|
|
743
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
736
744
|
});
|
|
737
745
|
});
|
|
738
746
|
|
|
@@ -21,6 +21,16 @@ import type { ApprovalContext } from "../approval.js";
|
|
|
21
21
|
import type { CheckPermissionDeps } from "../pipeline.js";
|
|
22
22
|
import { createPipelineDeps, createProductionClassifier, toSelector } from "../production.js";
|
|
23
23
|
|
|
24
|
+
// Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
|
|
25
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
26
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
29
|
+
getLogger: () => loggerMock,
|
|
30
|
+
createLogger: () => loggerMock,
|
|
31
|
+
setPiHandle: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
24
34
|
// vi.mock 必须在 import 之前(vitest hoisting)。替换 llm-shared 的 resolveModel + callLLM
|
|
25
35
|
// 为可控 mock,默认透传 actual(真实实现),单测用 mockImplementation/mockResolvedValue 精确控制。
|
|
26
36
|
vi.mock("@zhushanwen/pi-llm-shared", async (importOriginal) => {
|
|
@@ -174,14 +184,10 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
|
|
|
174
184
|
vi.mocked(resolveModelShared).mockReturnValue(MOCK_MODEL_A);
|
|
175
185
|
vi.mocked(callLLMShared).mockResolvedValue({ ok: true, content: "x" });
|
|
176
186
|
const classifier = createProductionClassifier(makeMockCtx());
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
expect(warnSpy).toHaveBeenCalledWith("[pi-permission] classifier: using model model-a");
|
|
182
|
-
} finally {
|
|
183
|
-
warnSpy.mockRestore();
|
|
184
|
-
}
|
|
187
|
+
loggerMock.warn.mockClear();
|
|
188
|
+
await classifier.classifyRisk(CTX, CFG_REF);
|
|
189
|
+
// onLog 实装为 logger.warn(production.ts),成功路径日志在 LLM 调用前输出
|
|
190
|
+
expect(loggerMock.warn).toHaveBeenCalledWith("[pi-permission] classifier: using model model-a");
|
|
185
191
|
});
|
|
186
192
|
|
|
187
193
|
it("TC5: callLLM 返回 ok:false(LLM 调用失败)→ classifier fail-closed fallback(不 throw)", async () => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* startupConfig 声明守护测试:断言 package.json `xyz-agent.startupConfig` 声明的
|
|
3
|
+
* content 与代码 DEFAULT_CONFIG 常量深相等,防止两侧任一改动未同步(漂移)。
|
|
4
|
+
* 机制:runtime 启动序列统一 ensure(extension-startup-config.ts),声明即首建内容。
|
|
5
|
+
*
|
|
6
|
+
* 运行:cd extensions/universal/permission && npx vitest run src/__tests__/startup-config-declaration.test.ts
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { DEFAULT_CONFIG } from "../types.js";
|
|
15
|
+
|
|
16
|
+
/** package.json `xyz-agent.startupConfig` 声明条目的最小形状(守护断言用)。 */
|
|
17
|
+
interface StartupConfigEntry {
|
|
18
|
+
path: string;
|
|
19
|
+
content: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pkg = JSON.parse(
|
|
23
|
+
readFileSync(join(fileURLToPath(import.meta.url), "../../../package.json"), "utf-8"),
|
|
24
|
+
) as { "xyz-agent"?: { startupConfig?: StartupConfigEntry[] } };
|
|
25
|
+
|
|
26
|
+
describe("startupConfig 声明守护", () => {
|
|
27
|
+
it("声明 content 与代码 DEFAULT_CONFIG 深相等", () => {
|
|
28
|
+
const entry = pkg["xyz-agent"]?.startupConfig?.find(
|
|
29
|
+
(e: StartupConfigEntry) => e.path === "config/permission-ext-config.json",
|
|
30
|
+
);
|
|
31
|
+
expect(entry).toBeDefined();
|
|
32
|
+
expect(entry?.content).toEqual(DEFAULT_CONFIG);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/ast/analyzer.ts
CHANGED
|
@@ -19,13 +19,19 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { Node, Tree } from "web-tree-sitter";
|
|
22
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
22
23
|
|
|
23
24
|
import type { BashAnalysis } from "../types.js";
|
|
24
25
|
import { getBashParser } from "./loader.js";
|
|
25
26
|
|
|
27
|
+
const logger = getLogger("pi-permission/ast");
|
|
28
|
+
|
|
26
29
|
/** 超过此长度的输入直接拒(防止 wasm 解析巨型 payload DoS)。 */
|
|
27
30
|
const MAX_COMMAND_LENGTH = 65536;
|
|
28
31
|
|
|
32
|
+
/** 带引号 token 的最小长度(首尾引号各占 1 字符);满足才能安全剥引号。 */
|
|
33
|
+
const MIN_QUOTED_TOKEN_LENGTH = 2;
|
|
34
|
+
|
|
29
35
|
/**
|
|
30
36
|
* Codex ALLOWED_KINDS(11 个 named node)。
|
|
31
37
|
* 遇到不在此集合的 named node 即判定为危险结构。
|
|
@@ -73,7 +79,7 @@ function parseDoubleQuotedString(node: Node): string | null {
|
|
|
73
79
|
}
|
|
74
80
|
const raw = node.text;
|
|
75
81
|
// 去掉首尾的 `"`(raw 形如 `"hello world"`)
|
|
76
|
-
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >=
|
|
82
|
+
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= MIN_QUOTED_TOKEN_LENGTH) {
|
|
77
83
|
return raw.slice(1, -1);
|
|
78
84
|
}
|
|
79
85
|
// 异常 grammar(理论上不会到这)—— 原样返回
|
|
@@ -89,7 +95,7 @@ function parseRawString(node: Node): string | null {
|
|
|
89
95
|
return null;
|
|
90
96
|
}
|
|
91
97
|
const raw = node.text;
|
|
92
|
-
if (raw.startsWith("'") && raw.endsWith("'") && raw.length >=
|
|
98
|
+
if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= MIN_QUOTED_TOKEN_LENGTH) {
|
|
93
99
|
return raw.slice(1, -1);
|
|
94
100
|
}
|
|
95
101
|
return raw;
|
|
@@ -230,7 +236,7 @@ export async function analyzeBashStructure(command: string): Promise<BashAnalysi
|
|
|
230
236
|
} catch (err) {
|
|
231
237
|
// 任何意外异常 → fail-closed
|
|
232
238
|
const msg = err instanceof Error ? err.message : String(err);
|
|
233
|
-
|
|
239
|
+
logger.warn("analyzeBashStructure exception", { error: msg });
|
|
234
240
|
return failClosed(msg || "EXCEPTION");
|
|
235
241
|
} finally {
|
|
236
242
|
// G1 critical: Tree 是 wasm 堆对象,不受 V8 GC 管,必须显式 delete 释放。
|
package/src/ast/loader.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - 初始化 Parser 运行时(locateFile 指向 wasm 目录)
|
|
7
7
|
* - 加载 bash grammar,构造并缓存 Parser 单例(promise memoization,天然并发互斥)
|
|
8
8
|
*
|
|
9
|
-
* 失败策略(fail-closed):任一步抛错 →
|
|
9
|
+
* 失败策略(fail-closed):任一步抛错 → logger.warn + 返回 null,永不 throw。
|
|
10
10
|
* analyzer 拿到 null 时降级为 { clean:false, parseError:true }。
|
|
11
11
|
*
|
|
12
12
|
* 注意(实测 web-tree-sitter 0.26.11 的事实,与 pi-lens 用的 0.24.5 有差异):
|
|
@@ -25,6 +25,9 @@ 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 { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
29
|
+
|
|
30
|
+
const logger = getLogger("pi-permission/ast");
|
|
28
31
|
|
|
29
32
|
const nodeRequire = createRequire(import.meta.url);
|
|
30
33
|
|
|
@@ -119,7 +122,7 @@ export function getBashParser(): Promise<Parser | null> {
|
|
|
119
122
|
// fail-closed:清空缓存让下次调用重试,warn 后返回 null。
|
|
120
123
|
parserPromise = null;
|
|
121
124
|
const msg = err instanceof Error ? err.message : String(err);
|
|
122
|
-
|
|
125
|
+
logger.warn("getBashParser init failed", { error: msg });
|
|
123
126
|
return null;
|
|
124
127
|
}
|
|
125
128
|
})();
|
package/src/commands.ts
CHANGED
|
@@ -55,6 +55,9 @@ export function handlePermissionCommand(
|
|
|
55
55
|
|
|
56
56
|
// ──────────────────────── 消息格式化 ────────────────────────
|
|
57
57
|
|
|
58
|
+
/** 模式列表 mode 列的 padEnd 宽度(最长 'approve' 7 字符 + 1 间隔,保证描述列对齐)。 */
|
|
59
|
+
const MODE_COLUMN_WIDTH = 8;
|
|
60
|
+
|
|
58
61
|
function formatStatusMessage(config: PermissionConfig): string {
|
|
59
62
|
const lines: string[] = [];
|
|
60
63
|
lines.push("[pi-permission] Current mode: " + modeHighlight(config.mode));
|
|
@@ -62,7 +65,7 @@ function formatStatusMessage(config: PermissionConfig): string {
|
|
|
62
65
|
lines.push("Available modes (by strictness, low → high):");
|
|
63
66
|
for (const mode of PERMISSION_MODES) {
|
|
64
67
|
const marker = mode === config.mode ? "►" : " ";
|
|
65
|
-
lines.push(` ${marker} ${mode.padEnd(
|
|
68
|
+
lines.push(` ${marker} ${mode.padEnd(MODE_COLUMN_WIDTH)} — ${MODE_DESCRIPTIONS[mode]}`);
|
|
66
69
|
}
|
|
67
70
|
lines.push("");
|
|
68
71
|
lines.push("Switch with: /permission <mode>");
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
loadConfig,
|
|
26
26
|
saveConfig as saveLlmConfig,
|
|
27
27
|
} from "@zhushanwen/pi-llm-shared";
|
|
28
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
28
29
|
|
|
29
30
|
import {
|
|
30
31
|
DEFAULT_CLASSIFIER_CONFIG,
|
|
@@ -38,6 +39,8 @@ import {
|
|
|
38
39
|
/** llm-shared 泛型 config 的包名(决定文件名 permission-ext-config.json,llm-shared getConfigPath 追加 -ext-config.json 后缀)。 */
|
|
39
40
|
const CONFIG_PKG = "permission";
|
|
40
41
|
|
|
42
|
+
const logger = getLogger("pi-permission");
|
|
43
|
+
|
|
41
44
|
// ──────────────────────── 路径解析 ────────────────────────
|
|
42
45
|
|
|
43
46
|
/** 配置文件完整路径:<agentDir>/config/permission-ext-config.json(llm-shared 推导)。 */
|
|
@@ -71,7 +74,7 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
|
|
|
71
74
|
// C3b:classifier.model 只接受 string('auto' 或 'provider/model-id')。对象形式(如
|
|
72
75
|
// `{ "type": "available" }`)不受支持,此前会被静默忽略回落默认——现在显式 warn 消除静默。
|
|
73
76
|
if (record.model !== undefined && !(typeof record.model === "string" && record.model.length > 0)) {
|
|
74
|
-
|
|
77
|
+
logger.warn("Ignoring invalid classifier.model (expected string 'auto' or 'provider/model-id'), using default auto");
|
|
75
78
|
}
|
|
76
79
|
// thinkingLevel 校验:合法值为 'off'|'minimal'|'low'|'medium'|'high'|'xhigh'|'max'
|
|
77
80
|
const thinkingLevel = isModelThinkingLevel(record.thinkingLevel)
|
|
@@ -113,8 +116,11 @@ function normalizeConfig(raw: unknown): PermissionConfig {
|
|
|
113
116
|
|
|
114
117
|
// ──────────────────────── 默认配置文件创建 ────────────────────────
|
|
115
118
|
|
|
119
|
+
/** 默认配置文件 JSON 序列化的缩进空格数。 */
|
|
120
|
+
const CONFIG_JSON_INDENT_SPACES = 2;
|
|
121
|
+
|
|
116
122
|
function createDefaultConfigContent(): string {
|
|
117
|
-
return `${JSON.stringify(DEFAULT_CONFIG, null,
|
|
123
|
+
return `${JSON.stringify(DEFAULT_CONFIG, null, CONFIG_JSON_INDENT_SPACES)}\n`;
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
function ensureConfigFile(configPath: string, onWarning?: (msg: string) => void): void {
|
|
@@ -140,8 +146,8 @@ function warnLegacyConfigIfExists(): void {
|
|
|
140
146
|
const legacyPath = join(getAgentDir(), "permission-config.json");
|
|
141
147
|
if (!existsSync(legacyPath)) return;
|
|
142
148
|
const newPath = join(getAgentDir(), "config", "permission-ext-config.json");
|
|
143
|
-
|
|
144
|
-
`
|
|
149
|
+
logger.warn(
|
|
150
|
+
`Legacy config detected at '${legacyPath}' but new config '${newPath}' is missing. ` +
|
|
145
151
|
`Migration did not run — defaulting to yolo mode, which may downgrade your previous strict/auto setting. ` +
|
|
146
152
|
`Remove the legacy file or move it to the new path after migrating.`,
|
|
147
153
|
);
|
package/src/footer-provider.ts
CHANGED
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
// 纯 globalThis 反射实现:不静态 import pi-statusline(permission 的可选 peerDep,
|
|
17
17
|
// 未安装时静态 import 会致整个 permission 加载失败),运行时结构兼容即可。
|
|
18
18
|
|
|
19
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
19
20
|
import { MODE_LABELS, type PermissionMode } from "./types.js";
|
|
20
21
|
import type { PermissionPalette } from "./statusline-palette.js";
|
|
21
22
|
|
|
23
|
+
const logger = getLogger("pi-permission");
|
|
24
|
+
|
|
22
25
|
// ── 协议契约字面量(必须与 pi-statusline 的 footer-handshake-access.ts 完全一致)──
|
|
23
26
|
// Symbol.for 字符串是跨包稳定契约:pi-statusline(owner) 与 permission(consumer) 通过同一
|
|
24
27
|
// Symbol 读/写 globalThis slot。字符串不一致 → 握手静默失败(双方各持独立 slot,永不对齐)。
|
|
@@ -81,9 +84,10 @@ function readSlot(): FooterHandshakeSlot | undefined {
|
|
|
81
84
|
if (!("version" in slot) || !("pending" in slot)) return undefined;
|
|
82
85
|
const loose = slot as { version: unknown; pending: unknown };
|
|
83
86
|
if (loose.version !== HANDSHAKE_VERSION) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
logger.warn("footer handshake version mismatch, discarding and recreating", {
|
|
88
|
+
got: String(loose.version),
|
|
89
|
+
expected: HANDSHAKE_VERSION,
|
|
90
|
+
});
|
|
87
91
|
return undefined;
|
|
88
92
|
}
|
|
89
93
|
if (!Array.isArray(loose.pending)) return undefined;
|
package/src/index.ts
CHANGED
|
@@ -18,9 +18,11 @@ import {
|
|
|
18
18
|
type ExtensionContext,
|
|
19
19
|
getAgentDir,
|
|
20
20
|
} from "@earendil-works/pi-coding-agent";
|
|
21
|
-
|
|
21
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
22
22
|
import { migrateLegacyConfig } from "@zhushanwen/pi-llm-shared";
|
|
23
23
|
|
|
24
|
+
const logger = getLogger("pi-permission");
|
|
25
|
+
|
|
24
26
|
import { listAvailableModels } from "./classifier/model-resolver.js";
|
|
25
27
|
import { handlePermissionCommand, handlePermissionModelCommand, handlePermissionRuleCommand } from "./commands.js";
|
|
26
28
|
import { loadAndWatchConfig, saveConfig } from "./config.js";
|
|
@@ -45,8 +47,8 @@ import { paletteFromTheme, type PermissionPalette } from "./statusline-palette.j
|
|
|
45
47
|
// 模块级 once flag 防同进程重复触发;agentDir 由 getAgentDir() 推导(尊重 PI_CODING_AGENT_DIR)。
|
|
46
48
|
let configMigrationChecked = false;
|
|
47
49
|
|
|
48
|
-
/** 默认配置 warning 回调(loadAndWatchConfig 共用,透传
|
|
49
|
-
const defaultConfigWarn = (msg: string): void =>
|
|
50
|
+
/** 默认配置 warning 回调(loadAndWatchConfig 共用,透传 logger.warn)。 */
|
|
51
|
+
const defaultConfigWarn = (msg: string): void => logger.warn(msg);
|
|
50
52
|
|
|
51
53
|
// ──────────────────────── tool_call event 最小子集 ────────────────────────
|
|
52
54
|
|
|
@@ -68,6 +70,14 @@ interface ToolCallResult {
|
|
|
68
70
|
reason?: string;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* ctx.ui.select / ctx.ui.input 的 opts 参数索引(第 3 个参数,0-based)。
|
|
75
|
+
* 类型位置提取 opts 时用 `Parameters<typeof ...>[typeof _UI_OPTIONS_PARAM_INDEX]`
|
|
76
|
+
* (字面量索引会触发 no-magic-numbers,typeof 常量索引语义等价)。
|
|
77
|
+
* 仅类型位置使用、运行时无值引用,按项目约定加 `_` 前缀豁免 no-unused-vars。
|
|
78
|
+
*/
|
|
79
|
+
const _UI_OPTIONS_PARAM_INDEX = 2;
|
|
80
|
+
|
|
71
81
|
// ──────────────────────── 扩展工厂 ────────────────────────
|
|
72
82
|
|
|
73
83
|
/**
|
|
@@ -133,7 +143,7 @@ export default function permissionExtension(pi: ExtensionAPI): void {
|
|
|
133
143
|
mode: ctx.mode,
|
|
134
144
|
ui: {
|
|
135
145
|
notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
|
|
136
|
-
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[
|
|
146
|
+
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
|
|
137
147
|
ctx.ui.select(title, options, opts),
|
|
138
148
|
custom: <T,>(
|
|
139
149
|
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
@@ -143,7 +153,7 @@ export default function permissionExtension(pi: ExtensionAPI): void {
|
|
|
143
153
|
// 连接 ctx.ui.input(rule-editor custom 模板文本输入用)。
|
|
144
154
|
// approval.ts 已声明可选 input(SDK 提供,mock 可能缺失)。
|
|
145
155
|
...(typeof ctx.ui.input === "function"
|
|
146
|
-
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[
|
|
156
|
+
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
|
|
147
157
|
: {}),
|
|
148
158
|
},
|
|
149
159
|
},
|
|
@@ -170,7 +180,7 @@ export default function permissionExtension(pi: ExtensionAPI): void {
|
|
|
170
180
|
modelRegistry: ctx.modelRegistry,
|
|
171
181
|
ui: {
|
|
172
182
|
notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
|
|
173
|
-
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[
|
|
183
|
+
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
|
|
174
184
|
ctx.ui.select(title, options, opts),
|
|
175
185
|
custom: <T,>(
|
|
176
186
|
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
@@ -179,13 +189,13 @@ export default function permissionExtension(pi: ExtensionAPI): void {
|
|
|
179
189
|
ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
|
|
180
190
|
// 连接 ctx.ui.input(与 rule handler 一致;model picker 当前不用,但保持 ctx 对称)。
|
|
181
191
|
...(typeof ctx.ui.input === "function"
|
|
182
|
-
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[
|
|
192
|
+
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
|
|
183
193
|
: {}),
|
|
184
194
|
},
|
|
185
195
|
},
|
|
186
196
|
config,
|
|
187
197
|
{
|
|
188
|
-
listModels: (pickerCtx) => listAvailableModels(pickerCtx, (m) =>
|
|
198
|
+
listModels: (pickerCtx) => listAvailableModels(pickerCtx, (m) => logger.warn(m)),
|
|
189
199
|
save: (newConfig) => {
|
|
190
200
|
const r = saveConfig(newConfig);
|
|
191
201
|
if (r.success) requestFooterRender();
|
|
@@ -313,7 +323,7 @@ async function processToolCall(
|
|
|
313
323
|
mode: ctx.mode,
|
|
314
324
|
ui: {
|
|
315
325
|
notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
|
|
316
|
-
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[
|
|
326
|
+
select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.select(title, options, opts),
|
|
317
327
|
custom: <T,>(
|
|
318
328
|
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
|
|
319
329
|
options?: { overlay?: boolean },
|
|
@@ -322,7 +332,7 @@ async function processToolCall(
|
|
|
322
332
|
// W6 T9 G3:Reject-with-Reason。ctx.ui.input 存在则透传(采集真实拒绝理由)。
|
|
323
333
|
// approval.ts 的 collectRejectReason 会用 typeof 判断是否可用,不可用则 fallback。
|
|
324
334
|
...(typeof ctx.ui.input === "function"
|
|
325
|
-
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[
|
|
335
|
+
? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
|
|
326
336
|
: {}),
|
|
327
337
|
},
|
|
328
338
|
};
|
|
@@ -352,7 +362,7 @@ async function processToolCall(
|
|
|
352
362
|
} catch (error) {
|
|
353
363
|
// fail-closed:异常 → block + reason(绝不放行)
|
|
354
364
|
const msg = error instanceof Error ? error.message : String(error);
|
|
355
|
-
|
|
365
|
+
logger.warn("tool_call handler exception", { toolName, error: msg });
|
|
356
366
|
return {
|
|
357
367
|
block: true,
|
|
358
368
|
reason: `[pi-permission] internal error (fail-closed): ${msg}`,
|
package/src/model-picker.ts
CHANGED
|
@@ -53,6 +53,9 @@ const AUTO_VALUE = "__auto__";
|
|
|
53
53
|
/** SelectList 最大可见行数(picker overlay 不超过终端高度)。 */
|
|
54
54
|
const MAX_VISIBLE_ITEMS = 10;
|
|
55
55
|
|
|
56
|
+
/** box 边框左右各占用 1 列(│ × 2)。 */
|
|
57
|
+
const BORDER_OVERHEAD = 2;
|
|
58
|
+
|
|
56
59
|
// ──────────────────────── 辅助组件(inline Component stub,避免依赖 Box 的 padding/bg) ────────────────────────
|
|
57
60
|
|
|
58
61
|
/**
|
|
@@ -127,14 +130,11 @@ export class ProviderModelSelectorComponent extends Container {
|
|
|
127
130
|
this.currentList?.handleInput(data);
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
/** box 边框左右各占用 1 列(│ × 2) */
|
|
131
|
-
private static readonly BORDER_OVERHEAD = 2;
|
|
132
|
-
|
|
133
133
|
/**
|
|
134
134
|
* 重写 render:用 box 边框包裹 Container 子组件的输出。
|
|
135
135
|
*/
|
|
136
136
|
override render(width: number): string[] {
|
|
137
|
-
const innerWidth = Math.max(0, width -
|
|
137
|
+
const innerWidth = Math.max(0, width - BORDER_OVERHEAD);
|
|
138
138
|
const inner = super.render(innerWidth);
|
|
139
139
|
const lines: string[] = [];
|
|
140
140
|
lines.push(`\u250C${"\u2500".repeat(innerWidth)}\u2510`);
|
package/src/pipeline.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* checkPermission 永不 throw(caller index.ts 的 tool_call handler 依赖此契约)。
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
19
20
|
import { matchRules } from "./rules/matcher.js";
|
|
20
21
|
import { wildcardToRegExp } from "./rules/wildcard.js";
|
|
21
22
|
import type {
|
|
@@ -39,6 +40,8 @@ import type {
|
|
|
39
40
|
// pipeline.js import 这些类型,不破坏现有 import 路径)。
|
|
40
41
|
export type { ApprovalRequest, CheckPermissionDeps } from "./types.js";
|
|
41
42
|
|
|
43
|
+
const logger = getLogger("pi-permission");
|
|
44
|
+
|
|
42
45
|
// ──────────────────────── buildApprovalRequest ────────────────────────
|
|
43
46
|
|
|
44
47
|
/**
|
|
@@ -533,9 +536,9 @@ function extractCommand(toolName: string, input: Record<string, unknown>): strin
|
|
|
533
536
|
if (typeof cmd === "string") return cmd;
|
|
534
537
|
// m3:bash + command 非字符串(异常输入)→ 记录警告,fail-closed 送下游
|
|
535
538
|
// (不静默跳过 AST,避免危险命令因类型异常绕过检查)。
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
+
logger.warn(
|
|
540
|
+
"bash tool received non-string command (skipping AST analysis, fail-closed: forwarded to downstream layers)",
|
|
541
|
+
{ type: typeof cmd },
|
|
539
542
|
);
|
|
540
543
|
return undefined;
|
|
541
544
|
}
|
package/src/production.ts
CHANGED
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
22
23
|
import { callLLM, resolveModel as resolveModelShared, type ModelSelector } from "@zhushanwen/pi-llm-shared";
|
|
23
24
|
|
|
24
25
|
import { type ApprovalContext, requestUserApproval } from "./approval.js";
|
|
26
|
+
|
|
27
|
+
const logger = getLogger("pi-permission");
|
|
25
28
|
import { analyzeBashStructure } from "./ast/index.js";
|
|
26
29
|
import { createClassifier } from "./classifier/index.js";
|
|
27
30
|
import type { CheckPermissionDeps } from "./pipeline.js";
|
|
@@ -65,7 +68,7 @@ export function createProductionClassifier(ctx: ExtensionContext): {
|
|
|
65
68
|
return resolveModelShared(ctx, toSelector(config.model));
|
|
66
69
|
},
|
|
67
70
|
callLLM: (opts) => callLLM(ctx, opts),
|
|
68
|
-
onLog: (msg: string) =>
|
|
71
|
+
onLog: (msg: string) => logger.warn(msg),
|
|
69
72
|
});
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* WR8:focusIndex 模 5(Custom form 5 项 Tab 循环)。
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { type Component, Container, Input, type SelectItem, SelectList, type SelectListTheme, truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
|
+
import { type Component, Container, Input, isFocusable, type SelectItem, SelectList, type SelectListTheme, truncateToWidth } from "@earendil-works/pi-tui";
|
|
15
15
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
16
16
|
|
|
17
17
|
import {
|
|
@@ -36,6 +36,19 @@ const THEME: SelectListTheme = {
|
|
|
36
36
|
|
|
37
37
|
const MAX_VISIBLE = 12;
|
|
38
38
|
|
|
39
|
+
/** box 边框左右各占用 1 列(│ × 2)。 */
|
|
40
|
+
const BORDER_OVERHEAD = 2;
|
|
41
|
+
|
|
42
|
+
/** custom form 焦点指示前缀(▶ + 空格,1 显示列 + 1 空格)。 */
|
|
43
|
+
const FOCUS_INDICATOR_PREFIX = "\u25B6 ";
|
|
44
|
+
|
|
45
|
+
/** scope 选择:all/subcmd 二选一全可见。 */
|
|
46
|
+
const SCOPE_LIST_MAX_VISIBLE = 2;
|
|
47
|
+
/** action 选择:allow/deny/ask 三选一全可见。 */
|
|
48
|
+
const ACTION_LIST_MAX_VISIBLE = 3;
|
|
49
|
+
/** submit 选择:Submit/Cancel 二选一全可见。 */
|
|
50
|
+
const SUBMIT_LIST_MAX_VISIBLE = 2;
|
|
51
|
+
|
|
39
52
|
// ──────────────────────── TextLines component ────────────────────────
|
|
40
53
|
|
|
41
54
|
class TextLines implements Component {
|
|
@@ -123,11 +136,8 @@ export class RuleEditorComponent extends Container {
|
|
|
123
136
|
|
|
124
137
|
// ──────────────────────── 边框渲染 ────────────────────────
|
|
125
138
|
|
|
126
|
-
/** box 边框左右各占用 1 列(│ × 2) */
|
|
127
|
-
private static readonly BORDER_OVERHEAD = 2;
|
|
128
|
-
|
|
129
139
|
override render(width: number): string[] {
|
|
130
|
-
const innerWidth = Math.max(0, width -
|
|
140
|
+
const innerWidth = Math.max(0, width - BORDER_OVERHEAD);
|
|
131
141
|
let inner = super.render(innerWidth);
|
|
132
142
|
|
|
133
143
|
// custom form 焦点指示(M4):当前焦点字段的标签加 ▶ 前缀
|
|
@@ -169,11 +179,11 @@ export class RuleEditorComponent extends Container {
|
|
|
169
179
|
return lines.map((line) => {
|
|
170
180
|
// 精确匹配当前焦点标签 → 加 ▶ 前缀
|
|
171
181
|
if (line === focusedLabel) {
|
|
172
|
-
return
|
|
182
|
+
return FOCUS_INDICATOR_PREFIX + line;
|
|
173
183
|
}
|
|
174
184
|
// 焦点已转移:去掉其他标签行残留的 ▶ 前缀(避免重复渲染累积)
|
|
175
|
-
if (line.startsWith(
|
|
176
|
-
return line.slice(
|
|
185
|
+
if (line.startsWith(FOCUS_INDICATOR_PREFIX) && fieldLabels.includes(line.slice(FOCUS_INDICATOR_PREFIX.length))) {
|
|
186
|
+
return line.slice(FOCUS_INDICATOR_PREFIX.length);
|
|
177
187
|
}
|
|
178
188
|
return line;
|
|
179
189
|
});
|
|
@@ -468,7 +478,7 @@ export class RuleEditorComponent extends Container {
|
|
|
468
478
|
{ value: "__all__", label: `${cmd} * (all subcommands)`, description: `Allow/deny all invocations of ${cmd}` },
|
|
469
479
|
{ value: "__subcmd__", label: `${cmd} <subcommand> *`, description: `Allow/deny a specific subcommand` },
|
|
470
480
|
];
|
|
471
|
-
const list = new SelectList(scopeItems,
|
|
481
|
+
const list = new SelectList(scopeItems, SCOPE_LIST_MAX_VISIBLE, this.theme);
|
|
472
482
|
list.onSelect = (item: SelectItem): void => {
|
|
473
483
|
if (item.value === "__all__") {
|
|
474
484
|
this.fillSelections.cmd = cmd;
|
|
@@ -674,7 +684,7 @@ export class RuleEditorComponent extends Container {
|
|
|
674
684
|
{ value: "deny", label: "deny" },
|
|
675
685
|
{ value: "ask", label: "ask" },
|
|
676
686
|
];
|
|
677
|
-
const actionList = new SelectList(actionItems,
|
|
687
|
+
const actionList = new SelectList(actionItems, ACTION_LIST_MAX_VISIBLE, this.theme);
|
|
678
688
|
actionList.onSelect = (item: SelectItem): void => {
|
|
679
689
|
this.fillSelections.action = item.value as PermissionAction;
|
|
680
690
|
};
|
|
@@ -709,7 +719,7 @@ export class RuleEditorComponent extends Container {
|
|
|
709
719
|
{ value: "__submit__", label: "[Submit]" },
|
|
710
720
|
{ value: "__cancel__", label: "[Cancel]" },
|
|
711
721
|
];
|
|
712
|
-
const submitList = new SelectList(submitItems,
|
|
722
|
+
const submitList = new SelectList(submitItems, SUBMIT_LIST_MAX_VISIBLE, this.theme);
|
|
713
723
|
submitList.onSelect = (item: SelectItem): void => {
|
|
714
724
|
if (item.value === "__submit__") {
|
|
715
725
|
this.commitFill();
|
|
@@ -875,8 +885,8 @@ export class RuleEditorComponent extends Container {
|
|
|
875
885
|
private syncCustomFocus(): void {
|
|
876
886
|
for (let i = 0; i < this.customChildren.length; i++) {
|
|
877
887
|
const child = this.customChildren[i];
|
|
878
|
-
if (child !== undefined &&
|
|
879
|
-
|
|
888
|
+
if (child !== undefined && isFocusable(child)) {
|
|
889
|
+
child.focused = i === this.customFocusIndex;
|
|
880
890
|
}
|
|
881
891
|
}
|
|
882
892
|
}
|
package/src/rules/builtins.ts
CHANGED
|
@@ -117,6 +117,9 @@ function executableNameLookupKey(raw: string): string | undefined {
|
|
|
117
117
|
|
|
118
118
|
// ── 合并短 flag 检查工具(G4: 合并 flag 如 -fo 需检测任意位置的 -o)──
|
|
119
119
|
|
|
120
|
+
/** 短 flag 簇(`-x`)的最小长度:`-` + 至少 1 个 flag 字符。 */
|
|
121
|
+
const MIN_SHORT_FLAG_CLUSTER_LENGTH = 2;
|
|
122
|
+
|
|
120
123
|
/**
|
|
121
124
|
* 检查短 flag 簇中是否含某个字符(如 `-fo` 含 `o`)。
|
|
122
125
|
*
|
|
@@ -129,7 +132,7 @@ function executableNameLookupKey(raw: string): string | undefined {
|
|
|
129
132
|
*/
|
|
130
133
|
function shortFlagClusterHasChar(arg: string, flagChar: string): boolean {
|
|
131
134
|
// 只处理短 flag 簇:单个 - 开头,非 --,长度 ≥ 2
|
|
132
|
-
if (!arg.startsWith("-") || arg.startsWith("--") || arg.length <
|
|
135
|
+
if (!arg.startsWith("-") || arg.startsWith("--") || arg.length < MIN_SHORT_FLAG_CLUSTER_LENGTH) return false;
|
|
133
136
|
// arg 去掉开头的 -,检查剩余字符
|
|
134
137
|
return arg.slice(1).includes(flagChar);
|
|
135
138
|
}
|