@zhushanwen/pi-permission 0.0.1
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/README.md +455 -0
- package/index.ts +2 -0
- package/package.json +52 -0
- package/src/__tests__/approval.test.ts +456 -0
- package/src/__tests__/commands.test.ts +487 -0
- package/src/__tests__/config.test.ts +229 -0
- package/src/__tests__/e2e-modes.test.ts +253 -0
- package/src/__tests__/extension.test.ts +170 -0
- package/src/__tests__/index-integration.test.ts +282 -0
- package/src/__tests__/model-picker.test.ts +371 -0
- package/src/__tests__/pipeline.test.ts +976 -0
- package/src/__tests__/production.test.ts +102 -0
- package/src/__tests__/rule-editor-component.test.ts +475 -0
- package/src/__tests__/rule-editor.test.ts +252 -0
- package/src/__tests__/rule-templates.test.ts +264 -0
- package/src/__tests__/statusline.test.ts +221 -0
- package/src/__tests__/types.test.ts +59 -0
- package/src/approval.ts +352 -0
- package/src/ast/__tests__/analyzer.test.ts +262 -0
- package/src/ast/__tests__/loader.test.ts +136 -0
- package/src/ast/analyzer.ts +326 -0
- package/src/ast/index.ts +7 -0
- package/src/ast/loader.ts +113 -0
- package/src/classifier/__tests__/classifier.test.ts +285 -0
- package/src/classifier/__tests__/json-parser.test.ts +118 -0
- package/src/classifier/__tests__/model-resolver.test.ts +173 -0
- package/src/classifier/__tests__/prompt.test.ts +59 -0
- package/src/classifier/classifier.ts +242 -0
- package/src/classifier/index.ts +28 -0
- package/src/classifier/json-parser.ts +127 -0
- package/src/classifier/model-resolver.ts +295 -0
- package/src/classifier/prompt.ts +52 -0
- package/src/commands.ts +234 -0
- package/src/config.ts +216 -0
- package/src/index.ts +291 -0
- package/src/model-picker.ts +398 -0
- package/src/pipeline.ts +541 -0
- package/src/production.ts +102 -0
- package/src/rule-editor-component.ts +883 -0
- package/src/rule-editor.ts +262 -0
- package/src/rule-templates.ts +332 -0
- package/src/rules/__tests__/builtins.test.ts +614 -0
- package/src/rules/__tests__/index.test.ts +26 -0
- package/src/rules/__tests__/matcher.test.ts +378 -0
- package/src/rules/__tests__/wildcard.test.ts +84 -0
- package/src/rules/builtins.ts +680 -0
- package/src/rules/index.ts +27 -0
- package/src/rules/matcher.ts +160 -0
- package/src/rules/wildcard.ts +43 -0
- package/src/statusline.ts +226 -0
- package/src/types.ts +249 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WT1-WT5/WT7: 配置加载/保存/mtime 缓存测试
|
|
3
|
+
*
|
|
4
|
+
* 用真实 fs + 临时目录(os.tmpdir + 随机子目录),不用 mock fs。
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { DEFAULT_CONFIG } from "../types.js";
|
|
12
|
+
import {
|
|
13
|
+
clearConfigCache,
|
|
14
|
+
getConfigPath,
|
|
15
|
+
loadAndWatchConfig,
|
|
16
|
+
saveConfig,
|
|
17
|
+
} from "../config.js";
|
|
18
|
+
|
|
19
|
+
let tempDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
tempDir = mkdtempSync(join(tmpdir(), "pi-perm-test-"));
|
|
23
|
+
clearConfigCache();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
28
|
+
clearConfigCache();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("WT1: 默认配置生成(首次无配置文件)", () => {
|
|
32
|
+
it("文件不存在时创建默认配置并返回", () => {
|
|
33
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
34
|
+
expect(existsSync(configPath)).toBe(false);
|
|
35
|
+
|
|
36
|
+
const config = loadAndWatchConfig(configPath);
|
|
37
|
+
|
|
38
|
+
expect(config.mode).toBe("yolo");
|
|
39
|
+
expect(config.enabled).toBe(true);
|
|
40
|
+
expect(config.classifier.enabled).toBe(true);
|
|
41
|
+
expect(config.classifier.model).toBe("auto");
|
|
42
|
+
expect(config.classifier.timeout).toBe(90);
|
|
43
|
+
expect(config.classifier.autoApproveLowRisk).toBe(true);
|
|
44
|
+
expect(config.classifier.autoDenyHighRisk).toBe(true);
|
|
45
|
+
expect(config.userRules).toEqual([]);
|
|
46
|
+
|
|
47
|
+
// 文件被创建
|
|
48
|
+
expect(existsSync(configPath)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("创建的文件是合法 JSON", () => {
|
|
52
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
53
|
+
loadAndWatchConfig(configPath);
|
|
54
|
+
|
|
55
|
+
const content = readFileSync(configPath, "utf-8");
|
|
56
|
+
expect(() => JSON.parse(content)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("WT2: 配置解析(合法配置)", () => {
|
|
61
|
+
it("正确解析完整配置", () => {
|
|
62
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
63
|
+
const rawConfig = {
|
|
64
|
+
mode: "auto",
|
|
65
|
+
enabled: true,
|
|
66
|
+
classifier: {
|
|
67
|
+
enabled: false,
|
|
68
|
+
model: "zhipu/glm-4-flash",
|
|
69
|
+
timeout: 30,
|
|
70
|
+
autoApproveLowRisk: false,
|
|
71
|
+
autoDenyHighRisk: false,
|
|
72
|
+
},
|
|
73
|
+
userRules: [
|
|
74
|
+
{ id: "user-1", tool: "bash", pattern: "git status", action: "allow", source: "user" },
|
|
75
|
+
{ id: "user-2", tool: "bash", pattern: "rm *", action: "deny", source: "user" },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
writeFileSync(configPath, JSON.stringify(rawConfig), "utf-8");
|
|
79
|
+
|
|
80
|
+
const config = loadAndWatchConfig(configPath);
|
|
81
|
+
|
|
82
|
+
expect(config.mode).toBe("auto");
|
|
83
|
+
expect(config.classifier.enabled).toBe(false);
|
|
84
|
+
expect(config.classifier.model).toBe("zhipu/glm-4-flash");
|
|
85
|
+
expect(config.classifier.timeout).toBe(30);
|
|
86
|
+
expect(config.classifier.autoApproveLowRisk).toBe(false);
|
|
87
|
+
expect(config.userRules).toHaveLength(2);
|
|
88
|
+
expect(config.userRules[0].tool).toBe("bash");
|
|
89
|
+
expect(config.userRules[0].pattern).toBe("git status");
|
|
90
|
+
expect(config.userRules[1].action).toBe("deny");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("WT3: 配置解析容错(malformed JSON)", () => {
|
|
95
|
+
it("malformed JSON fallback 到默认配置,不 throw", () => {
|
|
96
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
97
|
+
writeFileSync(configPath, "{ invalid json missing quotes:", "utf-8");
|
|
98
|
+
|
|
99
|
+
const warnings: string[] = [];
|
|
100
|
+
const config = loadAndWatchConfig(configPath, (msg) => warnings.push(msg));
|
|
101
|
+
|
|
102
|
+
expect(config.mode).toBe("yolo"); // 默认值
|
|
103
|
+
expect(config.enabled).toBe(true);
|
|
104
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
105
|
+
expect(warnings[0]).toContain("Config parse failed");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("mode 字段非法时 fallback 到 yolo", () => {
|
|
109
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
110
|
+
writeFileSync(configPath, JSON.stringify({ mode: "unknown-mode" }), "utf-8");
|
|
111
|
+
|
|
112
|
+
const config = loadAndWatchConfig(configPath);
|
|
113
|
+
expect(config.mode).toBe("yolo");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("classifier 字段缺失时用默认值", () => {
|
|
117
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
118
|
+
writeFileSync(configPath, JSON.stringify({ mode: "strict" }), "utf-8");
|
|
119
|
+
|
|
120
|
+
const config = loadAndWatchConfig(configPath);
|
|
121
|
+
expect(config.mode).toBe("strict");
|
|
122
|
+
expect(config.classifier.model).toBe("auto"); // 默认
|
|
123
|
+
expect(config.classifier.timeout).toBe(90); // 默认
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("userRules 含非法条目时过滤掉", () => {
|
|
127
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
128
|
+
writeFileSync(configPath, JSON.stringify({
|
|
129
|
+
mode: "yolo",
|
|
130
|
+
userRules: [
|
|
131
|
+
{ tool: "bash", pattern: "ls", action: "allow" }, // 合法
|
|
132
|
+
{ tool: "bash", pattern: "rm", action: "invalid-action" }, // 非法 action
|
|
133
|
+
{ pattern: "x" }, // 缺 tool
|
|
134
|
+
"not-an-object", // 非对象
|
|
135
|
+
],
|
|
136
|
+
}), "utf-8");
|
|
137
|
+
|
|
138
|
+
const config = loadAndWatchConfig(configPath);
|
|
139
|
+
expect(config.userRules).toHaveLength(1);
|
|
140
|
+
expect(config.userRules[0].pattern).toBe("ls");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("WT4: mtime 缓存(文件未变化时不重读)", () => {
|
|
145
|
+
it("连续两次调用,第二次返回缓存(同 mtime)", () => {
|
|
146
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
147
|
+
const original = loadAndWatchConfig(configPath);
|
|
148
|
+
const originalMode = original.mode;
|
|
149
|
+
|
|
150
|
+
// 不修改文件,第二次调用
|
|
151
|
+
const cached = loadAndWatchConfig(configPath);
|
|
152
|
+
|
|
153
|
+
// 返回的 config 应该与第一次一致(深相等)
|
|
154
|
+
expect(cached).toEqual(original);
|
|
155
|
+
expect(cached.mode).toBe(originalMode);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("WT5: mtime 缓存(文件变化时重读)", () => {
|
|
160
|
+
it("修改文件后第二次调用返回新内容", () => {
|
|
161
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
162
|
+
const first = loadAndWatchConfig(configPath);
|
|
163
|
+
expect(first.mode).toBe("yolo");
|
|
164
|
+
|
|
165
|
+
// 修改文件(写入新 mode)
|
|
166
|
+
writeFileSync(configPath, JSON.stringify({ mode: "strict" }), "utf-8");
|
|
167
|
+
|
|
168
|
+
// 注意:writeFileSync 可能不改变 mtime(如果写入太快),
|
|
169
|
+
// 用 utimes 显式更新 mtime 确保变化
|
|
170
|
+
const future = new Date(Date.now() + 2000);
|
|
171
|
+
utimesSync(configPath, future, future);
|
|
172
|
+
|
|
173
|
+
const second = loadAndWatchConfig(configPath);
|
|
174
|
+
expect(second.mode).toBe("strict");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("WT7: 保存配置", () => {
|
|
179
|
+
it("saveConfig 写入文件并更新缓存", () => {
|
|
180
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
181
|
+
const newConfig = { ...DEFAULT_CONFIG, mode: "strict" as const };
|
|
182
|
+
|
|
183
|
+
const result = saveConfig(newConfig, configPath);
|
|
184
|
+
expect(result.success).toBe(true);
|
|
185
|
+
|
|
186
|
+
// 文件被写入
|
|
187
|
+
expect(existsSync(configPath)).toBe(true);
|
|
188
|
+
const content = readFileSync(configPath, "utf-8");
|
|
189
|
+
const parsed = JSON.parse(content);
|
|
190
|
+
expect(parsed.mode).toBe("strict");
|
|
191
|
+
|
|
192
|
+
// 后续 loadAndWatchConfig 返回新配置(缓存已更新)
|
|
193
|
+
const loaded = loadAndWatchConfig(configPath);
|
|
194
|
+
expect(loaded.mode).toBe("strict");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("saveConfig 失败时返回 error(只读目录)", () => {
|
|
198
|
+
const readOnlyDir = join(tempDir, "readonly");
|
|
199
|
+
mkdirSync(readOnlyDir, { mode: 0o500 });
|
|
200
|
+
const configPath = join(readOnlyDir, "permission-config.json");
|
|
201
|
+
|
|
202
|
+
const result = saveConfig(DEFAULT_CONFIG, configPath);
|
|
203
|
+
// 可能成功也可能失败取决于系统权限实现,主要验证不 throw
|
|
204
|
+
expect(typeof result.success).toBe("boolean");
|
|
205
|
+
if (!result.success) {
|
|
206
|
+
expect(result.error).toBeTruthy();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("saveConfig 后文件权限是 0o600(仅用户可读写)", () => {
|
|
211
|
+
const configPath = join(tempDir, "permission-config.json");
|
|
212
|
+
saveConfig(DEFAULT_CONFIG, configPath);
|
|
213
|
+
|
|
214
|
+
const stat = statSync(configPath);
|
|
215
|
+
// macOS/Linux 下 mode & 0o777 应该是 0o600
|
|
216
|
+
// Windows 下文件权限模型不同,跳过此断容
|
|
217
|
+
if (process.platform !== "win32") {
|
|
218
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("getConfigPath", () => {
|
|
224
|
+
it("返回 ~/.pi/agent/permission-config.json 路径", () => {
|
|
225
|
+
const path = getConfigPath();
|
|
226
|
+
expect(path).toContain("permission-config.json");
|
|
227
|
+
expect(path).toContain(".pi");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* e2e-modes.test.ts — W6 T10: checkPermission 四档模式 e2e + G5 auto Racing e2e。
|
|
3
|
+
*
|
|
4
|
+
* 与 pipeline.test.ts 的区别:
|
|
5
|
+
* - pipeline.test.ts 测各层内部函数(runLayer2/applyAutoApproveOverrides/matchNonBashTool)
|
|
6
|
+
* - 本文件测 checkPermission 主入口的端到端行为(模拟完整 tool_call 流程)
|
|
7
|
+
*
|
|
8
|
+
* 5 个基础 e2e case(每档模式一个典型场景):
|
|
9
|
+
* - W6TC-E1: yolo + 危险命令 → allow(快速路径,不跑管道)
|
|
10
|
+
* - W6TC-E2: strict + 任意命令 → user 审批
|
|
11
|
+
* - W6TC-E3: approve + 安全命令(ls)→ rule allow
|
|
12
|
+
* - W6TC-E4: approve + 非安全命令 → user 审批
|
|
13
|
+
* - W6TC-E5: auto + 安全命令(ls)→ rule allow(不跑 AI)
|
|
14
|
+
*
|
|
15
|
+
* G5 补充 e2e case(W6 design-review gap):
|
|
16
|
+
* - W6TC-E6: auto + 未知命令 + mock classifier → AI Racing(层 3 完整流程)
|
|
17
|
+
* 当前 5 e2e 没覆盖 auto 层 3,本 case 补齐。
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, it, vi } from "vitest";
|
|
20
|
+
|
|
21
|
+
import { checkPermission, type CheckPermissionDeps } from "../pipeline.js";
|
|
22
|
+
import { getDefaultRules, matchRulesForArgv } from "../rules/index.js";
|
|
23
|
+
import type {
|
|
24
|
+
BashAnalysis,
|
|
25
|
+
ClassifierConfig,
|
|
26
|
+
ClassifierResult,
|
|
27
|
+
Rule,
|
|
28
|
+
RuleMatchResult,
|
|
29
|
+
ToolInvocationContext,
|
|
30
|
+
UserDecision,
|
|
31
|
+
} from "../types.js";
|
|
32
|
+
|
|
33
|
+
// ──────────────────────── mock helpers ────────────────────────
|
|
34
|
+
|
|
35
|
+
const CFG: ClassifierConfig = {
|
|
36
|
+
enabled: true,
|
|
37
|
+
model: "auto",
|
|
38
|
+
timeout: 90,
|
|
39
|
+
autoApproveLowRisk: true,
|
|
40
|
+
autoDenyHighRisk: true,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const ctxBase = { cwd: "/project", agentName: "test-agent" };
|
|
44
|
+
|
|
45
|
+
function cleanAnalysis(commands: string[][] = []): BashAnalysis {
|
|
46
|
+
return { clean: true, commands, dangerousStructures: [], parseError: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dirtyAnalysis(dangerous: string[] = ["subshell"]): BashAnalysis {
|
|
50
|
+
return { clean: false, commands: [], dangerousStructures: dangerous, parseError: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 构造完整 mock deps(模拟真实生产装配,但所有外部依赖可控)。
|
|
55
|
+
* 关键:用真实的 getDefaultRules + matchRulesForArgv(测真实规则引擎),
|
|
56
|
+
* 只 mock analyzeBashStructure(避免跑 wasm)+ classifier + requestUserApproval。
|
|
57
|
+
*/
|
|
58
|
+
function makeRealisticDeps(overrides: {
|
|
59
|
+
analyze?: (cmd: string) => Promise<BashAnalysis>;
|
|
60
|
+
classify?: (ctx: ToolInvocationContext, cfg: ClassifierConfig, signal?: AbortSignal) => Promise<ClassifierResult>;
|
|
61
|
+
approve?: (req: { toolName: string; command?: string; reason: string }, ctx: ToolInvocationContext, signal?: AbortSignal) => Promise<UserDecision>;
|
|
62
|
+
userRules?: Rule[];
|
|
63
|
+
} = {}): CheckPermissionDeps {
|
|
64
|
+
const userRules = overrides.userRules ?? [];
|
|
65
|
+
return {
|
|
66
|
+
analyzeBashStructure: overrides.analyze ?? (() => Promise.resolve(cleanAnalysis())),
|
|
67
|
+
// 用真实的 matchRulesForArgv(测真实规则引擎 + 白名单)
|
|
68
|
+
matchRulesForArgv: (argv: string[], rules: readonly Rule[]): RuleMatchResult => matchRulesForArgv(argv, rules),
|
|
69
|
+
// 用真实的 getDefaultRules + 用户规则
|
|
70
|
+
getDefaultRules: () => [...getDefaultRules(), ...userRules],
|
|
71
|
+
classifier: {
|
|
72
|
+
classifyRisk: overrides.classify ?? (() => Promise.resolve({ risk_level: "medium" as const, outcome: "ask" as const, reasoning: "fallback", confidence: 0 })),
|
|
73
|
+
},
|
|
74
|
+
isHeadless: () => false,
|
|
75
|
+
requestUserApproval:
|
|
76
|
+
overrides.approve ?? (() => Promise.resolve({ approved: false, reason: "default-deny" })),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function allowClassifier(risk: "low" | "medium" | "high" = "low"): ClassifierResult {
|
|
81
|
+
return { risk_level: risk, outcome: "allow", reasoning: "ai-allow", confidence: 0.9 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function denyClassifier(risk: "low" | "medium" | "high" = "high"): ClassifierResult {
|
|
85
|
+
return { risk_level: risk, outcome: "deny", reasoning: "ai-deny", confidence: 0.95 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ──────────────────────── W6TC-E1: yolo 快速路径 ────────────────────────
|
|
89
|
+
|
|
90
|
+
describe("W6TC-E1: yolo 模式 e2e(危险命令也放行)", () => {
|
|
91
|
+
it("yolo + rm -rf / → allow(source=mode,不跑管道)", async () => {
|
|
92
|
+
const analyze = vi.fn(() => Promise.resolve(cleanAnalysis([["rm", "-rf", "/"]])));
|
|
93
|
+
const deps = makeRealisticDeps({ analyze });
|
|
94
|
+
const decision = await checkPermission("bash", { command: "rm -rf /" }, "yolo", CFG, [], deps, ctxBase);
|
|
95
|
+
expect(decision.action).toBe("allow");
|
|
96
|
+
expect(decision.source).toBe("mode");
|
|
97
|
+
expect(analyze).not.toHaveBeenCalled(); // 快速路径不跑 AST
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ──────────────────────── W6TC-E2: strict 全审批 ────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("W6TC-E2: strict 模式 e2e(全部人工审批)", () => {
|
|
104
|
+
it("strict + ls → user 审批(用户 approve → allow)", async () => {
|
|
105
|
+
const approve = vi.fn(() => Promise.resolve<UserDecision>({ approved: true, reason: "ok" }));
|
|
106
|
+
const deps = makeRealisticDeps({ approve });
|
|
107
|
+
const decision = await checkPermission("bash", { command: "ls" }, "strict", CFG, [], deps, ctxBase);
|
|
108
|
+
expect(decision.action).toBe("allow");
|
|
109
|
+
expect(decision.source).toBe("user");
|
|
110
|
+
expect(approve).toHaveBeenCalledOnce();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("strict + 非 bash(read)→ user 审批(不跑 AST/规则)", async () => {
|
|
114
|
+
const approve = vi.fn(() => Promise.resolve<UserDecision>({ approved: false, reason: "no" }));
|
|
115
|
+
const deps = makeRealisticDeps({ approve });
|
|
116
|
+
const decision = await checkPermission("read", { path: "/etc/passwd" }, "strict", CFG, [], deps, ctxBase);
|
|
117
|
+
expect(decision.action).toBe("deny");
|
|
118
|
+
expect(decision.source).toBe("user");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ──────────────────────── W6TC-E3: approve + 安全命令 ────────────────────────
|
|
123
|
+
|
|
124
|
+
describe("W6TC-E3: approve 模式 e2e(安全命令规则放行)", () => {
|
|
125
|
+
it("approve + ls → rule allow(白名单,不跑 AI/审批)", async () => {
|
|
126
|
+
const approve = vi.fn(() => Promise.resolve<UserDecision>({ approved: true, reason: "should-not-reach" }));
|
|
127
|
+
const classify = vi.fn(() => Promise.resolve(allowClassifier()));
|
|
128
|
+
const deps = makeRealisticDeps({
|
|
129
|
+
analyze: () => Promise.resolve(cleanAnalysis([["ls", "-la"]])),
|
|
130
|
+
approve,
|
|
131
|
+
classify,
|
|
132
|
+
});
|
|
133
|
+
const decision = await checkPermission("bash", { command: "ls -la" }, "approve", CFG, [], deps, ctxBase);
|
|
134
|
+
expect(decision.action).toBe("allow");
|
|
135
|
+
expect(decision.source).toBe("rule");
|
|
136
|
+
expect(approve).not.toHaveBeenCalled();
|
|
137
|
+
expect(classify).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ──────────────────────── W6TC-E4: approve + 非安全命令 ────────────────────────
|
|
142
|
+
|
|
143
|
+
describe("W6TC-E4: approve 模式 e2e(非安全命令 → 人工审批)", () => {
|
|
144
|
+
it("approve + curl(非白名单)→ user 审批", async () => {
|
|
145
|
+
const approve = vi.fn(() => Promise.resolve<UserDecision>({ approved: false, reason: "denied" }));
|
|
146
|
+
const classify = vi.fn(() => Promise.resolve(allowClassifier()));
|
|
147
|
+
const deps = makeRealisticDeps({
|
|
148
|
+
analyze: () => Promise.resolve(cleanAnalysis([["curl", "http://example.com"]])),
|
|
149
|
+
approve,
|
|
150
|
+
classify,
|
|
151
|
+
});
|
|
152
|
+
const decision = await checkPermission("bash", { command: "curl http://example.com" }, "approve", CFG, [], deps, ctxBase);
|
|
153
|
+
expect(decision.action).toBe("deny");
|
|
154
|
+
expect(decision.source).toBe("user");
|
|
155
|
+
expect(approve).toHaveBeenCalledOnce();
|
|
156
|
+
expect(classify).not.toHaveBeenCalled(); // approve 不跑 AI
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ──────────────────────── W6TC-E5: auto + 安全命令 ────────────────────────
|
|
161
|
+
|
|
162
|
+
describe("W6TC-E5: auto 模式 e2e(安全命令规则放行,不跑 AI)", () => {
|
|
163
|
+
it("auto + git status → rule allow(白名单命中,不跑 AI)", async () => {
|
|
164
|
+
const classify = vi.fn(() => Promise.resolve(allowClassifier()));
|
|
165
|
+
const deps = makeRealisticDeps({
|
|
166
|
+
analyze: () => Promise.resolve(cleanAnalysis([["git", "status"]])),
|
|
167
|
+
classify,
|
|
168
|
+
});
|
|
169
|
+
const decision = await checkPermission("bash", { command: "git status" }, "auto", CFG, [], deps, ctxBase);
|
|
170
|
+
expect(decision.action).toBe("allow");
|
|
171
|
+
expect(decision.source).toBe("rule");
|
|
172
|
+
expect(classify).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ──────────────────────── W6TC-E6 (G5): auto + 未知命令 + mock classifier Racing ────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("W6TC-E6 (G5): auto + 未知命令 → AI 层 3 Racing e2e", () => {
|
|
179
|
+
it("auto + 未知命令(npm install)+ AI allow(low) → allow(source=ai)", async () => {
|
|
180
|
+
// npm 不在白名单 → 规则 ask → 进层 3 → AI allow(low) + autoApproveLowRisk=true → allow
|
|
181
|
+
const classify = vi.fn(() => Promise.resolve(allowClassifier("low")));
|
|
182
|
+
const approve = vi.fn(() => new Promise<UserDecision>(() => undefined)); // 用户永不返回(AI 先赢)
|
|
183
|
+
const deps = makeRealisticDeps({
|
|
184
|
+
analyze: () => Promise.resolve(cleanAnalysis([["npm", "install"]])),
|
|
185
|
+
classify,
|
|
186
|
+
approve,
|
|
187
|
+
});
|
|
188
|
+
const decision = await checkPermission("bash", { command: "npm install" }, "auto", CFG, [], deps, ctxBase);
|
|
189
|
+
expect(decision.action).toBe("allow");
|
|
190
|
+
expect(decision.source).toBe("ai");
|
|
191
|
+
expect(decision.riskLevel).toBe("low");
|
|
192
|
+
expect(classify).toHaveBeenCalledOnce();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("auto + 未知命令 + AI deny(high) → deny(source=ai)", async () => {
|
|
196
|
+
const classify = vi.fn(() => Promise.resolve(denyClassifier("high")));
|
|
197
|
+
const approve = vi.fn(() => new Promise<UserDecision>(() => undefined));
|
|
198
|
+
const deps = makeRealisticDeps({
|
|
199
|
+
analyze: () => Promise.resolve(cleanAnalysis([["wget", "http://evil.com"]])),
|
|
200
|
+
classify,
|
|
201
|
+
approve,
|
|
202
|
+
});
|
|
203
|
+
const decision = await checkPermission("bash", { command: "wget http://evil.com" }, "auto", CFG, [], deps, ctxBase);
|
|
204
|
+
expect(decision.action).toBe("deny");
|
|
205
|
+
expect(decision.source).toBe("ai");
|
|
206
|
+
expect(decision.riskLevel).toBe("high");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("auto + 未知命令 + AI ask → 转人工(source=user)", async () => {
|
|
210
|
+
// AI 返回 ask → 不 resolveUser → 等用户最终决策
|
|
211
|
+
const classify = vi.fn(() => Promise.resolve({ risk_level: "medium" as const, outcome: "ask" as const, reasoning: "uncertain", confidence: 0.5 }));
|
|
212
|
+
const approve = vi.fn(() => Promise.resolve<UserDecision>({ approved: true, reason: "user-says-ok" }));
|
|
213
|
+
const deps = makeRealisticDeps({
|
|
214
|
+
analyze: () => Promise.resolve(cleanAnalysis([["curl", "http://unknown.com"]])),
|
|
215
|
+
classify,
|
|
216
|
+
approve,
|
|
217
|
+
});
|
|
218
|
+
const decision = await checkPermission("bash", { command: "curl http://unknown.com" }, "auto", CFG, [], deps, ctxBase);
|
|
219
|
+
expect(decision.action).toBe("allow");
|
|
220
|
+
expect(decision.source).toBe("user");
|
|
221
|
+
expect(classify).toHaveBeenCalledOnce();
|
|
222
|
+
expect(approve).toHaveBeenCalledOnce();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("auto + 危险结构(subshell)→ 直接进层 3(AI 评估)", async () => {
|
|
226
|
+
// AST 检测到 subshell → clean=false → 直接进层 3(不走规则)
|
|
227
|
+
const classify = vi.fn(() => Promise.resolve(denyClassifier("high")));
|
|
228
|
+
const deps = makeRealisticDeps({
|
|
229
|
+
analyze: () => Promise.resolve(dirtyAnalysis(["subshell"])),
|
|
230
|
+
classify,
|
|
231
|
+
approve: () => new Promise<UserDecision>(() => undefined),
|
|
232
|
+
});
|
|
233
|
+
const decision = await checkPermission("bash", { command: "$(rm -rf /)" }, "auto", CFG, [], deps, ctxBase);
|
|
234
|
+
expect(decision.action).toBe("deny");
|
|
235
|
+
expect(decision.source).toBe("ai");
|
|
236
|
+
expect(classify).toHaveBeenCalledOnce();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("auto + 用户规则 deny 命中 → deny(source=rule,不跑 AI)", async () => {
|
|
240
|
+
// 用户写了 deny npm 规则 → 即使是未知命令也被规则 deny(last-match-wins)
|
|
241
|
+
const userDenyRule: Rule = { id: "u-deny-npm", tool: "bash", pattern: "npm *", action: "deny", source: "user" };
|
|
242
|
+
const classify = vi.fn(() => Promise.resolve(allowClassifier()));
|
|
243
|
+
const deps = makeRealisticDeps({
|
|
244
|
+
analyze: () => Promise.resolve(cleanAnalysis([["npm", "install"]])),
|
|
245
|
+
classify,
|
|
246
|
+
userRules: [userDenyRule],
|
|
247
|
+
});
|
|
248
|
+
const decision = await checkPermission("bash", { command: "npm install" }, "auto", CFG, [], deps, ctxBase);
|
|
249
|
+
expect(decision.action).toBe("deny");
|
|
250
|
+
expect(decision.source).toBe("rule");
|
|
251
|
+
expect(classify).not.toHaveBeenCalled(); // 规则 deny 不跑 AI
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WT8/WT9: 扩展入口注册 + 占位 tool_call 测试
|
|
3
|
+
*
|
|
4
|
+
* 用 mock pi 对象记录调用,不依赖真实 Pi 运行时。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
// Mock Pi SDK 模块(extension.ts import 的 @earendil-works/pi-coding-agent 类型在 node_modules 有完整定义,
|
|
9
|
+
// 这里 import 工厂函数,用 mock pi 调用它)
|
|
10
|
+
import permissionExtension from "../index.js";
|
|
11
|
+
|
|
12
|
+
/** 最小 mock:只记录 registerCommand 和 on 调用 */
|
|
13
|
+
interface MockPi {
|
|
14
|
+
registerCommand: (name: string, options: unknown) => void;
|
|
15
|
+
on: (event: string, handler: (event?: unknown, ctx?: unknown) => unknown) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RecordedCall {
|
|
19
|
+
name: string;
|
|
20
|
+
options: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createMockPi(): {
|
|
24
|
+
pi: MockPi;
|
|
25
|
+
registerCommandCalls: RecordedCall[];
|
|
26
|
+
eventHandlers: Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>>;
|
|
27
|
+
} {
|
|
28
|
+
const registerCommandCalls: RecordedCall[] = [];
|
|
29
|
+
const eventHandlers = new Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>>();
|
|
30
|
+
|
|
31
|
+
const pi: MockPi = {
|
|
32
|
+
registerCommand(name: string, options: unknown) {
|
|
33
|
+
registerCommandCalls.push({ name, options });
|
|
34
|
+
},
|
|
35
|
+
on(event: string, handler: (event?: unknown, ctx?: unknown) => unknown) {
|
|
36
|
+
if (!eventHandlers.has(event)) {
|
|
37
|
+
eventHandlers.set(event, []);
|
|
38
|
+
}
|
|
39
|
+
eventHandlers.get(event)!.push(handler);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return { pi, registerCommandCalls, eventHandlers };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 提取 handler options 的类型(运行时 duck typing) */
|
|
47
|
+
interface CommandOptions {
|
|
48
|
+
description: string;
|
|
49
|
+
handler: (args: string, ctx: { ui: { notify: (message: string, level: string) => void } }) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("WT8: 扩展入口注册", () => {
|
|
53
|
+
it("permissionExtension(pi) 注册 /permission 命令和事件处理器", () => {
|
|
54
|
+
const { pi, registerCommandCalls, eventHandlers } = createMockPi();
|
|
55
|
+
|
|
56
|
+
// 不应 throw
|
|
57
|
+
expect(() => permissionExtension(pi)).not.toThrow();
|
|
58
|
+
|
|
59
|
+
// 注册了 permission 命令
|
|
60
|
+
const permCommand = registerCommandCalls.find((c) => c.name === "permission");
|
|
61
|
+
expect(permCommand).toBeDefined();
|
|
62
|
+
const opts = permCommand!.options as CommandOptions;
|
|
63
|
+
expect(opts.description).toBeTruthy();
|
|
64
|
+
expect(typeof opts.handler).toBe("function");
|
|
65
|
+
|
|
66
|
+
// 注册了 session_start 事件
|
|
67
|
+
expect(eventHandlers.has("session_start")).toBe(true);
|
|
68
|
+
|
|
69
|
+
// 注册了 tool_call 事件(W1 占位)
|
|
70
|
+
expect(eventHandlers.has("tool_call")).toBe(true);
|
|
71
|
+
|
|
72
|
+
// W1 不注册 statusline(ctx.ui.setFooter 的 TUI Component 推到 W6)
|
|
73
|
+
expect(eventHandlers.has("statusline")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("permission 命令 handler 调用时返回状态消息(无参数)", () => {
|
|
77
|
+
const { pi, registerCommandCalls } = createMockPi();
|
|
78
|
+
permissionExtension(pi);
|
|
79
|
+
|
|
80
|
+
const permCommand = registerCommandCalls.find((c) => c.name === "permission");
|
|
81
|
+
const opts = permCommand!.options as CommandOptions;
|
|
82
|
+
const notifyCalls: Array<{ message: string; level: string }> = [];
|
|
83
|
+
const mockCtx = {
|
|
84
|
+
ui: {
|
|
85
|
+
notify(message: string, level: string) {
|
|
86
|
+
notifyCalls.push({ message, level });
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
opts.handler("", mockCtx);
|
|
92
|
+
|
|
93
|
+
expect(notifyCalls).toHaveLength(1);
|
|
94
|
+
expect(notifyCalls[0].message).toContain("Current mode");
|
|
95
|
+
expect(notifyCalls[0].message).toContain("yolo"); // 默认模式
|
|
96
|
+
expect(notifyCalls[0].level).toBe("info");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("permission 命令切换模式后 handler 显示新模式", () => {
|
|
100
|
+
const { pi, registerCommandCalls } = createMockPi();
|
|
101
|
+
permissionExtension(pi);
|
|
102
|
+
|
|
103
|
+
const permCommand = registerCommandCalls.find((c) => c.name === "permission");
|
|
104
|
+
const opts = permCommand!.options as CommandOptions;
|
|
105
|
+
const notifyCalls: Array<{ message: string; level: string }> = [];
|
|
106
|
+
const mockCtx = {
|
|
107
|
+
ui: {
|
|
108
|
+
notify(message: string, level: string) {
|
|
109
|
+
notifyCalls.push({ message, level });
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// 切换到 strict(会触发 saveConfig,但 saveConfig 写真实路径,这里用临时配置)
|
|
115
|
+
// 用 /permission status 避免写文件
|
|
116
|
+
opts.handler("status", mockCtx);
|
|
117
|
+
expect(notifyCalls[0].message).toContain("mode:");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("WT9: tool_call handler(W5 三层管道接入)", () => {
|
|
122
|
+
it("tool_call handler 存在且返回 Promise(不 throw)", () => {
|
|
123
|
+
const { pi, eventHandlers } = createMockPi();
|
|
124
|
+
permissionExtension(pi);
|
|
125
|
+
|
|
126
|
+
const toolCallHandlers = eventHandlers.get("tool_call")!;
|
|
127
|
+
expect(toolCallHandlers.length).toBe(1);
|
|
128
|
+
|
|
129
|
+
// 调用 handler 应该不 throw,且返回 Promise(W5 handler 是 async)
|
|
130
|
+
const result = toolCallHandlers[0](
|
|
131
|
+
{ toolName: "bash", input: { command: "rm -rf /" } },
|
|
132
|
+
{ mode: "yolo", cwd: "/tmp", ui: { notify() {}, select() { return Promise.resolve(undefined); }, custom() { return Promise.resolve(undefined); } } },
|
|
133
|
+
);
|
|
134
|
+
expect(result).toBeInstanceOf(Promise);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("对被规则放行的工具调用返回 undefined(不 block)", async () => {
|
|
138
|
+
const { pi, eventHandlers } = createMockPi();
|
|
139
|
+
permissionExtension(pi);
|
|
140
|
+
|
|
141
|
+
const handler = eventHandlers.get("tool_call")![0] as (
|
|
142
|
+
event: unknown,
|
|
143
|
+
ctx: unknown,
|
|
144
|
+
) => Promise<unknown>;
|
|
145
|
+
|
|
146
|
+
// 注:handler 实际读取磁盘上的 permission-config.json(无法从测试注入 config)。
|
|
147
|
+
// 默认 user config 含 allow 规则 `python *`(user-1),故 `python script.py` 命中 allow → resolve undefined。
|
|
148
|
+
// 此前测试断言「yolo 放行任意命令(含 git push --force)」依赖磁盘 config.mode==='yolo',
|
|
149
|
+
// 但 config 已改为 auto 且 bd-005 危险规则即使 yolo 之外的模式会拦截 force push。
|
|
150
|
+
// 这里改为断言「命中 allow 规则的命令被放行」,验证 handler 放行路径稳定可重现。
|
|
151
|
+
const mockCtx = {
|
|
152
|
+
mode: "yolo" as const,
|
|
153
|
+
cwd: "/tmp",
|
|
154
|
+
ui: {
|
|
155
|
+
notify() {},
|
|
156
|
+
select() {
|
|
157
|
+
return Promise.resolve(undefined);
|
|
158
|
+
},
|
|
159
|
+
custom() {
|
|
160
|
+
return Promise.resolve(undefined);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// 命中 user-1 allow 规则(pattern: "python *")→ 放行
|
|
166
|
+
await expect(
|
|
167
|
+
handler({ toolName: "bash", input: { command: "python script.py" } }, mockCtx),
|
|
168
|
+
).resolves.toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
});
|