@zhushanwen/pi-permission 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/config.test.ts +1 -1
- package/src/__tests__/extension.test.ts +1 -1
- package/src/__tests__/index-integration.test.ts +1 -1
- package/src/__tests__/session-start-once-guard.test.ts +1 -1
- package/src/ast/analyzer.ts +78 -68
- package/src/classifier/classifier.ts +80 -33
- package/src/pipeline.ts +103 -50
- package/src/rule-editor.ts +77 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
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",
|
|
@@ -50,9 +50,9 @@
|
|
|
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.2.
|
|
54
|
-
"@zhushanwen/pi-extension-logger": "0.4.
|
|
55
|
-
"@zhushanwen/pi-llm-shared": "0.
|
|
53
|
+
"@zhushanwen/pi-ext-guards": "0.2.1",
|
|
54
|
+
"@zhushanwen/pi-extension-logger": "0.4.1",
|
|
55
|
+
"@zhushanwen/pi-llm-shared": "0.6.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@earendil-works/pi-ai": "^0.84.4",
|
|
@@ -26,7 +26,7 @@ beforeEach(() => {
|
|
|
26
26
|
afterEach(() => {
|
|
27
27
|
if (originalAgentDirEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
28
28
|
else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv;
|
|
29
|
-
rmSync(tempAgentDir, { recursive: true, force: true });
|
|
29
|
+
rmSync(tempAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
/** 最小 mock:只记录 registerCommand 和 on 调用 */
|
|
@@ -140,7 +140,7 @@ afterEach(() => {
|
|
|
140
140
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
141
141
|
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
142
142
|
Reflect.deleteProperty(globalThis, REQUEST_RENDER_KEY);
|
|
143
|
-
if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
143
|
+
if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
144
144
|
});
|
|
145
145
|
|
|
146
146
|
// ──────────────────────── 测试 ────────────────────────
|
|
@@ -90,7 +90,7 @@ beforeEach(() => {
|
|
|
90
90
|
afterEach(() => {
|
|
91
91
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
92
92
|
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
93
|
-
if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
93
|
+
if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
describe("session_start 双派发幂等守卫(oncePerProcess,u-audit-fix)", () => {
|
package/src/ast/analyzer.ts
CHANGED
|
@@ -102,90 +102,100 @@ function parseRawString(node: Node): string | null {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* 5 分支处理 named children:
|
|
108
|
-
* 1. command_name → 其第一个 named child 必须是 word,取文本
|
|
109
|
-
* 2. word | number → 直接取文本
|
|
110
|
-
* 3. string → parseDoubleQuotedString(含 expansion 则 null)
|
|
111
|
-
* 4. raw_string → parseRawString
|
|
112
|
-
* 5. concatenation(如 -g"*.py")→ 递归拼接各 part(word/number/string/raw_string)
|
|
113
|
-
*
|
|
114
|
-
* 任一分支失败 → 返回 null(该 command 被跳过,但 clean 已在 DFS 阶段被置 false)。
|
|
105
|
+
* command_name 分支:其第一个 named child 必须是 word,取文本;否则 null。
|
|
115
106
|
*/
|
|
116
|
-
function
|
|
117
|
-
|
|
107
|
+
function parseCommandName(child: Node): string | null {
|
|
108
|
+
const wordNode = child.namedChild(0);
|
|
109
|
+
if (!wordNode || wordNode.type !== "word") {
|
|
118
110
|
return null;
|
|
119
111
|
}
|
|
112
|
+
return wordNode.text;
|
|
113
|
+
}
|
|
120
114
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
words.push(wordNode.text);
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
115
|
+
/**
|
|
116
|
+
* concatenation 分支(如 -g"*.py" 或 '/usr'"/"'local'/bin):
|
|
117
|
+
* 递归拼接各 part(word/number/string/raw_string),空拼接结果 → null。
|
|
118
|
+
*/
|
|
119
|
+
function parseConcatenation(child: Node): string | null {
|
|
120
|
+
let concatenated = "";
|
|
121
|
+
for (const part of child.namedChildren) {
|
|
122
|
+
switch (part.type) {
|
|
132
123
|
case "word":
|
|
133
|
-
case "number":
|
|
134
|
-
|
|
124
|
+
case "number":
|
|
125
|
+
concatenated += part.text;
|
|
135
126
|
break;
|
|
136
|
-
}
|
|
137
127
|
case "string": {
|
|
138
|
-
const parsed = parseDoubleQuotedString(
|
|
139
|
-
if (parsed === null)
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
words.push(parsed);
|
|
128
|
+
const parsed = parseDoubleQuotedString(part);
|
|
129
|
+
if (parsed === null) return null;
|
|
130
|
+
concatenated += parsed;
|
|
143
131
|
break;
|
|
144
132
|
}
|
|
145
133
|
case "raw_string": {
|
|
146
|
-
const parsed = parseRawString(
|
|
147
|
-
if (parsed === null)
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
words.push(parsed);
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
case "concatenation": {
|
|
154
|
-
// 拼接如 -g"*.py" 或 '/usr'"/"'local'/bin
|
|
155
|
-
let concatenated = "";
|
|
156
|
-
for (const part of child.namedChildren) {
|
|
157
|
-
switch (part.type) {
|
|
158
|
-
case "word":
|
|
159
|
-
case "number":
|
|
160
|
-
concatenated += part.text;
|
|
161
|
-
break;
|
|
162
|
-
case "string": {
|
|
163
|
-
const parsed = parseDoubleQuotedString(part);
|
|
164
|
-
if (parsed === null) return null;
|
|
165
|
-
concatenated += parsed;
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
-
case "raw_string": {
|
|
169
|
-
const parsed = parseRawString(part);
|
|
170
|
-
if (parsed === null) return null;
|
|
171
|
-
concatenated += parsed;
|
|
172
|
-
break;
|
|
173
|
-
}
|
|
174
|
-
default:
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (concatenated.length === 0) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
words.push(concatenated);
|
|
134
|
+
const parsed = parseRawString(part);
|
|
135
|
+
if (parsed === null) return null;
|
|
136
|
+
concatenated += parsed;
|
|
182
137
|
break;
|
|
183
138
|
}
|
|
184
139
|
default:
|
|
185
|
-
// 未知 named child(不应发生 —— DFS 阶段已拒,但保险起见 fail-closed)
|
|
186
140
|
return null;
|
|
187
141
|
}
|
|
188
142
|
}
|
|
143
|
+
if (concatenated.length === 0) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return concatenated;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 解析 command 的单个 named child 为一个 argv token(分发表)。
|
|
151
|
+
*
|
|
152
|
+
* 5 分支:
|
|
153
|
+
* 1. command_name → parseCommandName(第一个 named child 必须是 word)
|
|
154
|
+
* 2. word | number → 直接取文本
|
|
155
|
+
* 3. string → parseDoubleQuotedString(含 expansion 则 null)
|
|
156
|
+
* 4. raw_string → parseRawString
|
|
157
|
+
* 5. concatenation → parseConcatenation
|
|
158
|
+
*
|
|
159
|
+
* 未知 named child(不应发生 —— DFS 阶段已拒,但保险起见 fail-closed)→ null。
|
|
160
|
+
* 失败 → 调用方让整个 command 返回 null(该 command 被跳过,但 clean 已在 DFS 阶段被置 false)。
|
|
161
|
+
*/
|
|
162
|
+
function parseChildToken(child: Node): string | null {
|
|
163
|
+
switch (child.type) {
|
|
164
|
+
case "command_name":
|
|
165
|
+
return parseCommandName(child);
|
|
166
|
+
case "word":
|
|
167
|
+
case "number":
|
|
168
|
+
return child.text;
|
|
169
|
+
case "string":
|
|
170
|
+
return parseDoubleQuotedString(child);
|
|
171
|
+
case "raw_string":
|
|
172
|
+
return parseRawString(child);
|
|
173
|
+
case "concatenation":
|
|
174
|
+
return parseConcatenation(child);
|
|
175
|
+
default:
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 从一个 command 节点提取 argv(对应 Rust parse_plain_command_from_node)。
|
|
182
|
+
*
|
|
183
|
+
* 主函数编排:类型守卫 → 逐 named child 分发(parseChildToken)→ 组装 words。
|
|
184
|
+
* 任一 child 解析失败 → 返回 null。
|
|
185
|
+
*/
|
|
186
|
+
function parsePlainCommand(cmd: Node): string[] | null {
|
|
187
|
+
if (cmd.type !== "command") {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const words: string[] = [];
|
|
192
|
+
for (const child of cmd.namedChildren) {
|
|
193
|
+
const token = parseChildToken(child);
|
|
194
|
+
if (token === null) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
words.push(token);
|
|
198
|
+
}
|
|
189
199
|
return words;
|
|
190
200
|
}
|
|
191
201
|
|
|
@@ -119,6 +119,78 @@ function raceResultWithDeadline(
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// ──────────────────────── classifyRisk 分步 helper ────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 构造 CallLLMOptions(timeout 秒→毫秒的换算由调用方完成后传入;
|
|
126
|
+
* signal 透传 callLLM → completeSimple,abort 时 reject 或 resolve(aborted)
|
|
127
|
+
* 都会归一为 ok:false)。
|
|
128
|
+
*/
|
|
129
|
+
function buildCallOptions(
|
|
130
|
+
model: Model<Api>,
|
|
131
|
+
ctx: ToolInvocationContext,
|
|
132
|
+
config: ClassifierConfig,
|
|
133
|
+
timeoutMs: number | undefined,
|
|
134
|
+
signal: AbortSignal | undefined,
|
|
135
|
+
): CallLLMOptions {
|
|
136
|
+
return {
|
|
137
|
+
model,
|
|
138
|
+
systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
|
|
139
|
+
messages: buildMessages(ctx),
|
|
140
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
141
|
+
...(signal !== undefined ? { signal } : {}),
|
|
142
|
+
// thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
|
|
143
|
+
reasoning: config.thinkingLevel,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 记录 LLM 调用失败日志(G3 语义:abort 与 error 分开记,即使行为上两者都 fallback 无差别)。 */
|
|
148
|
+
function logFailedCall(
|
|
149
|
+
result: Extract<CallLLMResult, { ok: false }>,
|
|
150
|
+
onLog?: (msg: string) => void,
|
|
151
|
+
): void {
|
|
152
|
+
if (result.stopReason === "aborted") {
|
|
153
|
+
onLog?.(`[pi-permission] classifier: LLM call aborted (stopReason=aborted), returning fallback`);
|
|
154
|
+
} else {
|
|
155
|
+
const stopDetail = result.stopReason !== undefined ? ` (stopReason=${result.stopReason})` : "";
|
|
156
|
+
onLog?.(`[pi-permission] classifier: LLM call failed: ${result.error}${stopDetail}, returning fallback`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** raceWithDeadline 已收窄的结果形态(timeout/aborted 无 result 字段)。 */
|
|
161
|
+
type SettledRace =
|
|
162
|
+
| { kind: "ok"; result: CallLLMResult }
|
|
163
|
+
| { kind: "timeout" }
|
|
164
|
+
| { kind: "aborted" };
|
|
165
|
+
|
|
166
|
+
/** settleRacedResult 的输出:fallback = fail-closed ask;ok = 继续解析文本。 */
|
|
167
|
+
type SettledOutcome =
|
|
168
|
+
| { kind: "fallback"; result: ClassifierResult }
|
|
169
|
+
| { kind: "ok"; result: Extract<CallLLMResult, { ok: true }> };
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* race 结果归一:timeout / aborted / ok:false → fail-closed fallback(保留日志区分),
|
|
173
|
+
* 成功原样透传(调用方收窄后取 content)。
|
|
174
|
+
*/
|
|
175
|
+
function settleRacedResult(settled: SettledRace, timeoutMs: number | undefined, onLog?: (msg: string) => void): SettledOutcome {
|
|
176
|
+
if (settled.kind === "timeout") {
|
|
177
|
+
onLog?.(`[pi-permission] classifier: timed out after ${timeoutMs}ms`);
|
|
178
|
+
return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
|
|
179
|
+
}
|
|
180
|
+
if (settled.kind === "aborted") {
|
|
181
|
+
onLog?.(`[pi-permission] classifier: aborted by signal`);
|
|
182
|
+
return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ok:false → fallback(fail-closed)。stopReason 独立透传字段保留日志区分
|
|
186
|
+
const result = settled.result;
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
logFailedCall(result, onLog);
|
|
189
|
+
return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
|
|
190
|
+
}
|
|
191
|
+
return { kind: "ok", result };
|
|
192
|
+
}
|
|
193
|
+
|
|
122
194
|
// ──────────────────────── createClassifier ────────────────────────
|
|
123
195
|
|
|
124
196
|
/**
|
|
@@ -148,21 +220,12 @@ export function createClassifier(deps: ClassifierDeps): {
|
|
|
148
220
|
// 实证 classifier 真实用到 OAuth/配置的模型(而非 fail-closed 降级)。
|
|
149
221
|
onLog?.(`[pi-permission] classifier: using model ${model.id}`);
|
|
150
222
|
|
|
151
|
-
// 2. 构造 CallLLMOptions
|
|
152
|
-
// abort 时 reject 或 resolve(aborted) 都会归一为 ok:false)
|
|
223
|
+
// 2. 构造 CallLLMOptions
|
|
153
224
|
const timeoutMs = config.timeout > 0 ? config.timeout * MILLIS_PER_SECOND : undefined;
|
|
154
|
-
const callPromise = callLLM(
|
|
155
|
-
model,
|
|
156
|
-
systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
|
|
157
|
-
messages: buildMessages(ctx),
|
|
158
|
-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
159
|
-
...(signal !== undefined ? { signal } : {}),
|
|
160
|
-
// thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
|
|
161
|
-
reasoning: config.thinkingLevel,
|
|
162
|
-
});
|
|
225
|
+
const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal));
|
|
163
226
|
|
|
164
227
|
// 3. 等待 callLLM + 外层超时/中止兜底(provider 不支持 timeoutMs/signal 时防永挂)
|
|
165
|
-
let settled:
|
|
228
|
+
let settled: SettledRace;
|
|
166
229
|
try {
|
|
167
230
|
settled = await raceResultWithDeadline(callPromise, timeoutMs, signal);
|
|
168
231
|
} catch (error) {
|
|
@@ -171,30 +234,14 @@ export function createClassifier(deps: ClassifierDeps): {
|
|
|
171
234
|
return { ...CLASSIFY_FALLBACK_RESULT };
|
|
172
235
|
}
|
|
173
236
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (settled.kind === "aborted") {
|
|
179
|
-
onLog?.(`[pi-permission] classifier: aborted by signal`);
|
|
180
|
-
return { ...CLASSIFY_FALLBACK_RESULT };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// 4. ok:false → fallback(fail-closed)。stopReason 独立透传字段保留日志区分
|
|
184
|
-
// (G3 语义:abort 与 error 分开记,即使行为上两者都 fallback 无差别)。
|
|
185
|
-
const result = settled.result;
|
|
186
|
-
if (!result.ok) {
|
|
187
|
-
if (result.stopReason === "aborted") {
|
|
188
|
-
onLog?.(`[pi-permission] classifier: LLM call aborted (stopReason=aborted), returning fallback`);
|
|
189
|
-
} else {
|
|
190
|
-
const stopDetail = result.stopReason !== undefined ? ` (stopReason=${result.stopReason})` : "";
|
|
191
|
-
onLog?.(`[pi-permission] classifier: LLM call failed: ${result.error}${stopDetail}, returning fallback`);
|
|
192
|
-
}
|
|
193
|
-
return { ...CLASSIFY_FALLBACK_RESULT };
|
|
237
|
+
// 4. timeout / aborted / ok:false → fail-closed fallback;成功 → 收窄透传
|
|
238
|
+
const settledOutcome = settleRacedResult(settled, timeoutMs, onLog);
|
|
239
|
+
if (settledOutcome.kind === "fallback") {
|
|
240
|
+
return settledOutcome.result;
|
|
194
241
|
}
|
|
195
242
|
|
|
196
243
|
// 5. 提取文本 → 三层容错解析
|
|
197
|
-
return parseClassifierResponse(result.content);
|
|
244
|
+
return parseClassifierResponse(settledOutcome.result.content);
|
|
198
245
|
}
|
|
199
246
|
|
|
200
247
|
return { classifyRisk };
|
package/src/pipeline.ts
CHANGED
|
@@ -442,8 +442,101 @@ export async function runLayer3WithRacing(
|
|
|
442
442
|
|
|
443
443
|
// ──────────────────────── checkPermission(纯函数主入口) ────────────────────────
|
|
444
444
|
|
|
445
|
+
/** ctxBase 形态(checkPermission 第四参之后注入的上下文基线)。 */
|
|
446
|
+
interface ContextBase {
|
|
447
|
+
cwd: string;
|
|
448
|
+
agentName?: string;
|
|
449
|
+
signal?: AbortSignal;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 从 input 装配 ToolInvocationContext(command 用 AST/规则,path 其余字段透传给 UI)。 */
|
|
453
|
+
function buildInvocationContext(
|
|
454
|
+
toolName: string,
|
|
455
|
+
command: string | undefined,
|
|
456
|
+
input: Record<string, unknown>,
|
|
457
|
+
ctxBase: ContextBase,
|
|
458
|
+
): ToolInvocationContext {
|
|
459
|
+
return {
|
|
460
|
+
toolName,
|
|
461
|
+
...(command !== undefined ? { command } : {}),
|
|
462
|
+
path: typeof input.path === "string" ? input.path : undefined,
|
|
463
|
+
cwd: ctxBase.cwd,
|
|
464
|
+
...(ctxBase.agentName !== undefined ? { agentName: ctxBase.agentName } : {}),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** 层 2 allow/deny 结果 → PermissionDecision 装配(ask 返回 null 交下游)。 */
|
|
469
|
+
function ruleDecision(layer2: RuleMatchResult): PermissionDecision | null {
|
|
470
|
+
if (layer2.action === "allow") {
|
|
471
|
+
return {
|
|
472
|
+
action: "allow",
|
|
473
|
+
reason: layer2.matchedRule
|
|
474
|
+
? `allowed by rule ${layer2.matchedRule.id}`
|
|
475
|
+
: "allowed by rules (no deny matched)",
|
|
476
|
+
source: "rule",
|
|
477
|
+
...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (layer2.action === "deny") {
|
|
481
|
+
return {
|
|
482
|
+
action: "deny",
|
|
483
|
+
reason: layer2.matchedRule
|
|
484
|
+
? `denied by rule ${layer2.matchedRule.id}`
|
|
485
|
+
: "denied by rules",
|
|
486
|
+
source: "rule",
|
|
487
|
+
...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** 层 1 AST 输出:层 2 需要的 argvList + 提前决策(null = 无提前决策,继续层 2)。 */
|
|
494
|
+
interface AstLayerOutcome {
|
|
495
|
+
argvList: string[][];
|
|
496
|
+
earlyDecision: PermissionDecision | null;
|
|
497
|
+
}
|
|
498
|
+
|
|
445
499
|
/**
|
|
446
|
-
*
|
|
500
|
+
* 层 1 AST(仅 bash 且有 command 时执行):clean → 透传 argvList 继续;
|
|
501
|
+
* 非 clean / 异常 → 按模式提前决策(approve 人工 / auto 进层 3 Racing)。
|
|
502
|
+
*/
|
|
503
|
+
async function runAstLayer(
|
|
504
|
+
toolName: string,
|
|
505
|
+
command: string | undefined,
|
|
506
|
+
mode: PermissionMode,
|
|
507
|
+
config: ClassifierConfig,
|
|
508
|
+
ctx: ToolInvocationContext,
|
|
509
|
+
deps: CheckPermissionDeps,
|
|
510
|
+
ctxBase: ContextBase,
|
|
511
|
+
): Promise<AstLayerOutcome> {
|
|
512
|
+
let argvList: string[][] = [];
|
|
513
|
+
if (toolName === "bash" && command !== undefined) {
|
|
514
|
+
try {
|
|
515
|
+
const analysis = await deps.analyzeBashStructure(command);
|
|
516
|
+
argvList = analysis.commands;
|
|
517
|
+
// AST 检测到危险结构(非 clean)→ 直接 ask(auto→AI,approve→人工)
|
|
518
|
+
if (!analysis.clean) {
|
|
519
|
+
const trigger = `AST detected dangerous structure: ${analysis.dangerousStructures.join(", ") || "unknown"}`;
|
|
520
|
+
if (mode === "approve") {
|
|
521
|
+
return { argvList, earlyDecision: await askUser(deps, ctx, trigger, ctxBase.signal) };
|
|
522
|
+
}
|
|
523
|
+
// auto:进层 3(AI 评估危险命令)。m2:透传 AST 检测的具体危险原因。
|
|
524
|
+
return { argvList, earlyDecision: await runLayer3WithRacing(deps, ctx, config, ctxBase.signal, trigger) };
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
// AST 异常 → fail-closed ask(auto→AI,approve→人工)
|
|
528
|
+
if (mode === "approve") {
|
|
529
|
+
return { argvList, earlyDecision: await askUser(deps, ctx, "AST analysis failed (fail-closed)", ctxBase.signal) };
|
|
530
|
+
}
|
|
531
|
+
return { argvList, earlyDecision: await runLayer3WithRacing(deps, ctx, config, ctxBase.signal) };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return { argvList, earlyDecision: null };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 权限检查主入口(纯函数,deps 注入)。主函数只留编排:
|
|
539
|
+
* yolo/strict 快速路径 → 层 1 AST(runAstLayer)→ 层 2 规则(ruleDecision)→ 下游。
|
|
447
540
|
*
|
|
448
541
|
* 四档模式分支:
|
|
449
542
|
* - yolo:完全放行(return allow,source='mode')。不跑任何层。
|
|
@@ -471,7 +564,7 @@ export async function checkPermission(
|
|
|
471
564
|
config: ClassifierConfig,
|
|
472
565
|
userRules: Rule[],
|
|
473
566
|
deps: CheckPermissionDeps,
|
|
474
|
-
ctxBase:
|
|
567
|
+
ctxBase: ContextBase,
|
|
475
568
|
): Promise<PermissionDecision> {
|
|
476
569
|
// yolo / disabled → 完全放行
|
|
477
570
|
if (mode === "yolo") {
|
|
@@ -479,13 +572,7 @@ export async function checkPermission(
|
|
|
479
572
|
}
|
|
480
573
|
|
|
481
574
|
const command = extractCommand(toolName, input);
|
|
482
|
-
const ctx
|
|
483
|
-
toolName,
|
|
484
|
-
...(command !== undefined ? { command } : {}),
|
|
485
|
-
path: typeof input.path === "string" ? input.path : undefined,
|
|
486
|
-
cwd: ctxBase.cwd,
|
|
487
|
-
...(ctxBase.agentName !== undefined ? { agentName: ctxBase.agentName } : {}),
|
|
488
|
-
};
|
|
575
|
+
const ctx = buildInvocationContext(toolName, command, input, ctxBase);
|
|
489
576
|
|
|
490
577
|
// strict → 全部人工审批(不跑 AST/规则/AI)
|
|
491
578
|
if (mode === "strict") {
|
|
@@ -493,54 +580,20 @@ export async function checkPermission(
|
|
|
493
580
|
}
|
|
494
581
|
|
|
495
582
|
// auto / approve:层 1 AST + 层 2 规则
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
|
|
499
|
-
const analysis = await deps.analyzeBashStructure(command);
|
|
500
|
-
argvList = analysis.commands;
|
|
501
|
-
// AST 检测到危险结构(非 clean)→ 直接 ask(auto→AI,approve→人工)
|
|
502
|
-
if (!analysis.clean) {
|
|
503
|
-
const trigger = `AST detected dangerous structure: ${analysis.dangerousStructures.join(", ") || "unknown"}`;
|
|
504
|
-
if (mode === "approve") {
|
|
505
|
-
return await askUser(deps, ctx, trigger, ctxBase.signal);
|
|
506
|
-
}
|
|
507
|
-
// auto:进层 3(AI 评估危险命令)。m2:透传 AST 检测的具体危险原因。
|
|
508
|
-
return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal, trigger);
|
|
509
|
-
}
|
|
510
|
-
} catch {
|
|
511
|
-
// AST 异常 → fail-closed ask(auto→AI,approve→人工)
|
|
512
|
-
if (mode === "approve") {
|
|
513
|
-
return await askUser(deps, ctx, "AST analysis failed (fail-closed)", ctxBase.signal);
|
|
514
|
-
}
|
|
515
|
-
return await runLayer3WithRacing(deps, ctx, config, ctxBase.signal);
|
|
516
|
-
}
|
|
583
|
+
const ast = await runAstLayer(toolName, command, mode, config, ctx, deps, ctxBase);
|
|
584
|
+
if (ast.earlyDecision !== null) {
|
|
585
|
+
return ast.earlyDecision;
|
|
517
586
|
}
|
|
518
587
|
|
|
519
588
|
// 层 2 规则(auto + approve 共用)
|
|
520
589
|
const rules = [...deps.getDefaultRules(), ...userRules];
|
|
521
590
|
// C1:传入完整 command(bash 跨 argv 管道 deny 补充检查);
|
|
522
591
|
// M5:传入 path(非 bash 工具规则对 path 匹配)。
|
|
523
|
-
const layer2 = runLayer2ForArgvList(toolName, argvList, rules, deps, command, ctx.path);
|
|
592
|
+
const layer2 = runLayer2ForArgvList(toolName, ast.argvList, rules, deps, command, ctx.path);
|
|
524
593
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
reason: layer2.matchedRule
|
|
529
|
-
? `allowed by rule ${layer2.matchedRule.id}`
|
|
530
|
-
: "allowed by rules (no deny matched)",
|
|
531
|
-
source: "rule",
|
|
532
|
-
...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
if (layer2.action === "deny") {
|
|
536
|
-
return {
|
|
537
|
-
action: "deny",
|
|
538
|
-
reason: layer2.matchedRule
|
|
539
|
-
? `denied by rule ${layer2.matchedRule.id}`
|
|
540
|
-
: "denied by rules",
|
|
541
|
-
source: "rule",
|
|
542
|
-
...(layer2.matchedRule ? { matchedRule: layer2.matchedRule } : {}),
|
|
543
|
-
};
|
|
594
|
+
const decided = ruleDecision(layer2);
|
|
595
|
+
if (decided !== null) {
|
|
596
|
+
return decided;
|
|
544
597
|
}
|
|
545
598
|
|
|
546
599
|
// layer2.action === 'ask' → 下游
|
package/src/rule-editor.ts
CHANGED
|
@@ -91,35 +91,74 @@ async function editViaTui(
|
|
|
91
91
|
|
|
92
92
|
// ──────────────────────── RPC 分支(G16) ────────────────────────
|
|
93
93
|
|
|
94
|
+
/** RPC 循环内的 select/input 句柄(deps mock 优先,缺省回落 ctx.ui)。 */
|
|
95
|
+
interface RpcIo {
|
|
96
|
+
select: (title: string, options: string[]) => Promise<string | undefined>;
|
|
97
|
+
input: (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
98
|
+
}
|
|
99
|
+
|
|
94
100
|
/**
|
|
95
|
-
* RPC
|
|
101
|
+
* 解析 RPC 循环的 input 句柄:
|
|
102
|
+
* ctx.ui.input 可用 → 用真实文本输入(custom 模板需要自由文本);
|
|
103
|
+
* 不可用 → fallback 把 input 降级为单选项 select(占位符文本,仅保底)。
|
|
104
|
+
*/
|
|
105
|
+
function resolveRpcInput(ctx: RuleEditorContext, rpcDeps?: RuleEditorRpcDeps): RpcIo["input"] {
|
|
106
|
+
return rpcDeps?.input
|
|
107
|
+
?? (typeof ctx.ui.input === "function"
|
|
108
|
+
? (title: string, ph?: string) => ctx.ui.input!(title, ph)
|
|
109
|
+
: (title: string, ph?: string) => ctx.ui.select(title, [ph ?? ""]).then((v) => v === undefined ? undefined : v));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 单轮 RPC list 画面:当前规则集(applyOps 反映前序操作)+ 标签 + 选项。 */
|
|
113
|
+
function buildRpcListState(
|
|
114
|
+
initialRules: readonly Rule[],
|
|
115
|
+
ops: RuleOp[],
|
|
116
|
+
): { currentRules: Rule[]; ruleLabels: string[]; listOptions: string[] } {
|
|
117
|
+
const currentRules = applyOps([...initialRules], ops);
|
|
118
|
+
const ruleLabels = currentRules.map((r) => `[${r.action}] ${r.pattern}`);
|
|
119
|
+
const listOptions = [...ruleLabels, "[+ Add rule]", "[Done]"];
|
|
120
|
+
return { currentRules, ruleLabels, listOptions };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 选择了一条现有规则 → edit 模式(rpcEditFlow;ops 原地追加产生的 op)。
|
|
125
|
+
*/
|
|
126
|
+
async function handleRpcRuleSelection(
|
|
127
|
+
io: RpcIo,
|
|
128
|
+
listChoice: string,
|
|
129
|
+
ruleLabels: readonly string[],
|
|
130
|
+
currentRules: readonly Rule[],
|
|
131
|
+
ops: RuleOp[],
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const ruleIdx = ruleLabels.indexOf(listChoice);
|
|
134
|
+
if (ruleIdx >= 0 && ruleIdx < currentRules.length) {
|
|
135
|
+
const existingRule = currentRules[ruleIdx];
|
|
136
|
+
if (existingRule !== undefined) {
|
|
137
|
+
const editOp = await rpcEditFlow(io.select, io.input, existingRule);
|
|
138
|
+
if (editOp !== undefined) {
|
|
139
|
+
ops.push(editOp);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* RPC 循环主体:list → action select → fill input → ... 直到 Done / cancel。
|
|
96
147
|
*
|
|
97
148
|
* G16:每次 op 后 listOptions 更新(反映前序操作)。
|
|
98
|
-
* deps 注入 select/input 便于测试 mock。
|
|
99
149
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
150
|
+
async function runRpcLoop(
|
|
151
|
+
io: RpcIo,
|
|
102
152
|
initialRules: readonly Rule[],
|
|
103
153
|
sessionIdCounter: () => string,
|
|
104
|
-
rpcDeps?: RuleEditorRpcDeps,
|
|
105
154
|
): Promise<RuleEditorResult> {
|
|
106
|
-
const select = rpcDeps?.select ?? ctx.ui.select;
|
|
107
|
-
// ctx.ui.input 可用 → 用真实文本输入(custom 模板需要自由文本);
|
|
108
|
-
// 不可用 → fallback 把 input 降级为单选项 select(占位符文本,仅保底)。
|
|
109
|
-
const input = rpcDeps?.input
|
|
110
|
-
?? (typeof ctx.ui.input === "function"
|
|
111
|
-
? (title: string, ph?: string) => ctx.ui.input!(title, ph)
|
|
112
|
-
: (title: string, ph?: string) => ctx.ui.select(title, [ph ?? ""]).then((v) => v === undefined ? undefined : v));
|
|
113
|
-
|
|
114
155
|
const ops: RuleOp[] = [];
|
|
115
156
|
|
|
116
157
|
for (;;) {
|
|
117
158
|
// 构建 listOptions(G16:用 applyOps 反映前序操作)
|
|
118
|
-
const currentRules =
|
|
119
|
-
const ruleLabels = currentRules.map((r) => `[${r.action}] ${r.pattern}`);
|
|
120
|
-
const listOptions = [...ruleLabels, "[+ Add rule]", "[Done]"];
|
|
159
|
+
const { currentRules, ruleLabels, listOptions } = buildRpcListState(initialRules, ops);
|
|
121
160
|
|
|
122
|
-
const listChoice = await select("[pi-permission] Permission Rules", listOptions);
|
|
161
|
+
const listChoice = await io.select("[pi-permission] Permission Rules", listOptions);
|
|
123
162
|
if (listChoice === undefined) {
|
|
124
163
|
// cancel
|
|
125
164
|
return undefined;
|
|
@@ -130,7 +169,7 @@ export async function editViaRpc(
|
|
|
130
169
|
}
|
|
131
170
|
|
|
132
171
|
if (listChoice === "[+ Add rule]") {
|
|
133
|
-
const op = await rpcAddFlow(select, input, sessionIdCounter);
|
|
172
|
+
const op = await rpcAddFlow(io.select, io.input, sessionIdCounter);
|
|
134
173
|
if (op !== undefined) {
|
|
135
174
|
ops.push(op);
|
|
136
175
|
}
|
|
@@ -138,19 +177,29 @@ export async function editViaRpc(
|
|
|
138
177
|
}
|
|
139
178
|
|
|
140
179
|
// 选择了一条现有规则 → edit 模式
|
|
141
|
-
|
|
142
|
-
if (ruleIdx >= 0 && ruleIdx < currentRules.length) {
|
|
143
|
-
const existingRule = currentRules[ruleIdx];
|
|
144
|
-
if (existingRule !== undefined) {
|
|
145
|
-
const editOp = await rpcEditFlow(select, input, existingRule);
|
|
146
|
-
if (editOp !== undefined) {
|
|
147
|
-
ops.push(editOp);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
180
|
+
await handleRpcRuleSelection(io, listChoice, ruleLabels, currentRules, ops);
|
|
151
181
|
}
|
|
152
182
|
}
|
|
153
183
|
|
|
184
|
+
/**
|
|
185
|
+
* RPC 循环编辑:list → action select → fill input → ... 直到 Done / cancel。
|
|
186
|
+
*
|
|
187
|
+
* G16:每次 op 后 listOptions 更新(反映前序操作)。
|
|
188
|
+
* deps 注入 select/input 便于测试 mock。
|
|
189
|
+
*/
|
|
190
|
+
export async function editViaRpc(
|
|
191
|
+
ctx: RuleEditorContext,
|
|
192
|
+
initialRules: readonly Rule[],
|
|
193
|
+
sessionIdCounter: () => string,
|
|
194
|
+
rpcDeps?: RuleEditorRpcDeps,
|
|
195
|
+
): Promise<RuleEditorResult> {
|
|
196
|
+
const io: RpcIo = {
|
|
197
|
+
select: rpcDeps?.select ?? ctx.ui.select,
|
|
198
|
+
input: resolveRpcInput(ctx, rpcDeps),
|
|
199
|
+
};
|
|
200
|
+
return await runRpcLoop(io, initialRules, sessionIdCounter);
|
|
201
|
+
}
|
|
202
|
+
|
|
154
203
|
/** RPC add flow:选模板 → fill。 */
|
|
155
204
|
async function rpcAddFlow(
|
|
156
205
|
select: (title: string, options: string[]) => Promise<string | undefined>,
|