@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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 规则匹配引擎(W3 层 2 核心入口)。
|
|
3
|
+
*
|
|
4
|
+
* 三个 critical gap 修正(design-review):
|
|
5
|
+
*
|
|
6
|
+
* ── G1: no-match 兜底语义 ──
|
|
7
|
+
* 无匹配返回 `{ action: 'ask', matchedRule: undefined }`(非 'deny')。
|
|
8
|
+
* deny 会让 auto 模式所有未匹配命令被拦截,无法到 AI 层。ask 让未匹配命令
|
|
9
|
+
* 进入下游(auto 模式 → AI classifier;approve/strict 模式 → 人工审批)。
|
|
10
|
+
*
|
|
11
|
+
* ── G2: pattern 双语义 ──
|
|
12
|
+
* source='builtin-danger' 的 pattern 是 RegExp 源字符串(含 `\b`/`\s`),
|
|
13
|
+
* 用 `new RegExp(pattern, 'i')` 编译;其余(builtin-safe/user)是 OpenCode
|
|
14
|
+
* wildcard,用 `wildcardToRegExp(pattern)` 编译。resolvePattern 按 source 分发。
|
|
15
|
+
*
|
|
16
|
+
* ── G3: 24+5 白名单用函数(C3 修正:不再短路)──
|
|
17
|
+
* matchRulesForArgv 先遍历 rules(last-match-wins);若无 deny/allow 命中(ask),
|
|
18
|
+
* 再查 isKnownSafeCommand(argv) 兜底返回 allow。白名单不再短路:用户 deny 规则
|
|
19
|
+
* 可覆盖白名单。白名单不进 Rule[] 数组(Codex flag 子检查无法用单条 wildcard 表达)。
|
|
20
|
+
*
|
|
21
|
+
* 匹配语义:last-match-wins(rules 数组顺序遍历,最后匹配的 rule 胜出)。
|
|
22
|
+
* 拼接顺序由调用方(W5)负责:[...builtin-danger, ...userRules]。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Rule, RuleMatchResult } from "../types.js";
|
|
26
|
+
import { isKnownSafeCommand } from "./builtins.js";
|
|
27
|
+
import { wildcardToRegExp } from "./wildcard.js";
|
|
28
|
+
|
|
29
|
+
// ──────────────────────── pattern 缓存(module-level 状态)────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* pattern → RegExp 缓存,key = `source + ':' + pattern`。
|
|
33
|
+
*
|
|
34
|
+
* 唯一的 module-level 可变状态(matcher 是纯函数,缓存为性能优化,不改变语义)。
|
|
35
|
+
* 进程级生命周期,无需手动清理(pattern 集合有限且有界)。
|
|
36
|
+
*/
|
|
37
|
+
const patternCache = new Map<string, RegExp>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 把 rule.pattern 编译为 RegExp,按 source 分发(G2)。
|
|
41
|
+
*
|
|
42
|
+
* - source='builtin-danger':pattern 是 RegExp 源字符串 → `new RegExp(pattern, 'i')`
|
|
43
|
+
* - 其余(builtin-safe/user):pattern 是 OpenCode wildcard → `wildcardToRegExp(pattern)`
|
|
44
|
+
*
|
|
45
|
+
* 结果缓存到 patternCache。
|
|
46
|
+
*/
|
|
47
|
+
export function resolvePattern(rule: Rule): RegExp {
|
|
48
|
+
const cacheKey = rule.source + ":" + rule.pattern;
|
|
49
|
+
const cached = patternCache.get(cacheKey);
|
|
50
|
+
if (cached !== undefined) return cached;
|
|
51
|
+
|
|
52
|
+
const re =
|
|
53
|
+
rule.source === "builtin-danger"
|
|
54
|
+
? new RegExp(rule.pattern, "i")
|
|
55
|
+
: wildcardToRegExp(rule.pattern);
|
|
56
|
+
|
|
57
|
+
patternCache.set(cacheKey, re);
|
|
58
|
+
return re;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ──────────────────────── matchRulesForArgv(happy path,W2 clean=true 有 argv[])────────────────────────
|
|
62
|
+
|
|
63
|
+
/** 命中 isKnownSafeCommand 时构造的虚拟 builtin-safe rule(保持 matchedRule 字段一致性)。 */
|
|
64
|
+
function virtualSafeRule(argv0: string): Rule {
|
|
65
|
+
return {
|
|
66
|
+
id: "builtin-safe",
|
|
67
|
+
tool: "bash",
|
|
68
|
+
pattern: argv0,
|
|
69
|
+
action: "allow",
|
|
70
|
+
source: "builtin-safe",
|
|
71
|
+
description: "known safe command (Codex safelist)",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 对已 tokenize 的 argv 做规则匹配(happy path)。
|
|
77
|
+
*
|
|
78
|
+
* 顺序(C3 修正:白名单不再短路,用户 deny 可覆盖白名单,符合 last-match-wins 契约):
|
|
79
|
+
* 1. 遍历 rules(last-match-wins),对每条用 resolvePattern(rule).test(argv.join(' '))
|
|
80
|
+
* 记录 winner。
|
|
81
|
+
* 2. winner.action === 'deny' → 返回 deny(用户/builtin deny 优先于白名单)
|
|
82
|
+
* 3. winner.action === 'allow' → 返回 allow
|
|
83
|
+
* 4. winner.action === 'ask'(无匹配):
|
|
84
|
+
* - isKnownSafeCommand(argv) → allow(白名单兜底,虚拟 builtin-safe rule)
|
|
85
|
+
* - 否则 → ask
|
|
86
|
+
* 5. 空 argv → ask(G1)
|
|
87
|
+
*
|
|
88
|
+
* 匹配目标字符串统一为 `argv.join(' ')`(builtin-danger 正则与 user wildcard 都对它匹配)。
|
|
89
|
+
*/
|
|
90
|
+
export function matchRulesForArgv(argv: string[], rules: readonly Rule[]): RuleMatchResult {
|
|
91
|
+
if (argv.length === 0) {
|
|
92
|
+
// 空命令无法匹配任何规则 → ask(G1)
|
|
93
|
+
return { action: "ask", matchedRule: undefined };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const commandStr = argv.join(" ");
|
|
97
|
+
|
|
98
|
+
let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
|
|
99
|
+
for (const rule of rules) {
|
|
100
|
+
const re = resolvePattern(rule);
|
|
101
|
+
if (re.test(commandStr)) {
|
|
102
|
+
winner = { action: rule.action, matchedRule: rule };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// deny / allow 直接由 winner 决定(用户规则可覆盖白名单)
|
|
107
|
+
if (winner.action === "deny" || winner.action === "allow") {
|
|
108
|
+
return winner;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// winner.action === 'ask'(无规则匹配)→ 白名单兜底
|
|
112
|
+
if (isKnownSafeCommand(argv)) {
|
|
113
|
+
return { action: "allow", matchedRule: virtualSafeRule(argv[0] ?? "") };
|
|
114
|
+
}
|
|
115
|
+
return winner;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ──────────────────────── matchRules(退化路径,command 字符串)────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 对原始 command 字符串做规则匹配(退化路径 + C1 完整字符串补充检查)。
|
|
122
|
+
*
|
|
123
|
+
* 两种使用场景:
|
|
124
|
+
* 1. 退化路径:W2 AST 解析失败(parseError)或调用方只拿到 command 字符串
|
|
125
|
+
* (无法可靠 tokenize argv)。
|
|
126
|
+
* 2. C1 补充检查:pipeline.ts 对 bash 工具的完整 command 字符串做一次额外
|
|
127
|
+
* builtin-danger deny 匹配,覆盖「curl | sh」这类跨 argv 管道命令(AST 按
|
|
128
|
+
* 管道拆分后单个 argv 不含 `|`,deny 规则的正则无法命中)。
|
|
129
|
+
*
|
|
130
|
+
* 行为:
|
|
131
|
+
* - toolName !== 'bash' → ask(W3 只管 bash,其他工具交下游)
|
|
132
|
+
* - command === undefined / 空字符串 → ask
|
|
133
|
+
* - 遍历 rules(last-match-wins),对每条用 resolvePattern(rule).test(command)
|
|
134
|
+
* - 无匹配 → ask(G1)
|
|
135
|
+
*
|
|
136
|
+
* 不调 isKnownSafeCommand(不查白名单):command 字符串无法可靠提取 argv
|
|
137
|
+
* (含引号/转义/管道),argv 级白名单判断由 matchRulesForArgv 在 happy path 负责。
|
|
138
|
+
* 因此本函数仅用于 deny 补充检查或退化路径,不用作 allow 放行依据。
|
|
139
|
+
*/
|
|
140
|
+
export function matchRules(
|
|
141
|
+
toolName: string,
|
|
142
|
+
command: string | undefined,
|
|
143
|
+
rules: readonly Rule[],
|
|
144
|
+
): RuleMatchResult {
|
|
145
|
+
if (toolName !== "bash") {
|
|
146
|
+
return { action: "ask", matchedRule: undefined };
|
|
147
|
+
}
|
|
148
|
+
if (command === undefined || command === "") {
|
|
149
|
+
return { action: "ask", matchedRule: undefined };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let winner: RuleMatchResult = { action: "ask", matchedRule: undefined };
|
|
153
|
+
for (const rule of rules) {
|
|
154
|
+
const re = resolvePattern(rule);
|
|
155
|
+
if (re.test(command)) {
|
|
156
|
+
winner = { action: rule.action, matchedRule: rule };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return winner;
|
|
160
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode 风格 wildcard → RegExp 转换。
|
|
3
|
+
*
|
|
4
|
+
* 移植自 opencode-anomaly/packages/core/src/util/wildcard.ts。
|
|
5
|
+
* 语义:
|
|
6
|
+
* - 路径分隔符统一为 '/'(replaceAll('\\','/'))
|
|
7
|
+
* - 元字符转义:`[.+^${}()|[\]\\]` → 加 `\` 前缀
|
|
8
|
+
* - `*` → `.*`(跨任意字符,含空格)
|
|
9
|
+
* - `?` → `.`(单字符)
|
|
10
|
+
* - 末尾 ` *` 改写为 `( .*)?`(使 `ls *` 也能匹配无参 `ls`)
|
|
11
|
+
* - 全锚定 `^...$`;非 win32 用 `s` flag(dotAll),win32 加 `i`(大小写不敏感)
|
|
12
|
+
*
|
|
13
|
+
* 注意:本模块只对 pattern 做 wildcard 编译,不对 input 做路径归一化
|
|
14
|
+
* (matcher 层调用方负责把 argv.join(' ') 作为 input 传入)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** 非 win32 平台始终返回同一个 flag 字符串,便于上层缓存 key 稳定。 */
|
|
18
|
+
function flags(): string {
|
|
19
|
+
return process.platform === "win32" ? "si" : "s";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 把 OpenCode 风格 wildcard pattern 编译为全锚定 RegExp。
|
|
24
|
+
*
|
|
25
|
+
* 纯函数:相同输入始终产生等价 RegExp(process.platform 在进程生命周期内稳定)。
|
|
26
|
+
*/
|
|
27
|
+
export function wildcardToRegExp(pattern: string): RegExp {
|
|
28
|
+
let escaped = pattern
|
|
29
|
+
.replaceAll("\\", "/")
|
|
30
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
31
|
+
.replace(/\*/g, ".*")
|
|
32
|
+
.replace(/\?/g, ".");
|
|
33
|
+
|
|
34
|
+
// 末尾 ` *`(已转写为 ` .*`)改写为可选的 `( .*)?`,
|
|
35
|
+
// 使 `ls *` 这类 pattern 也能匹配无参 `ls`。
|
|
36
|
+
const TRAILING_WILDCARD = " .*";
|
|
37
|
+
const OPTIONAL_WILDCARD = "( .*)?";
|
|
38
|
+
if (escaped.endsWith(TRAILING_WILDCARD)) {
|
|
39
|
+
escaped = escaped.slice(0, -TRAILING_WILDCARD.length) + OPTIONAL_WILDCARD;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new RegExp("^" + escaped + "$", flags());
|
|
43
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Permission statusline — footer 集成(W6 T1)。
|
|
3
|
+
*
|
|
4
|
+
* 通过 ctx.ui.setFooter(factory) 注册一个 footer factory,在 TUI 底部显示当前
|
|
5
|
+
* 权限模式标签(YOLO/AUTO/APPROVE/STRICT)+ enabled 状态。
|
|
6
|
+
*
|
|
7
|
+
* 设计参考 extensions/statusline/src/index.ts:84-136 的 UiWithFooter 桥接模式:
|
|
8
|
+
* - SDK 未声明 setFooter 的精确类型(stub 为 `setFooter(factory: unknown): void`),
|
|
9
|
+
* 故本地定义 UiWithFooter 接口,用 `ctx.ui as unknown as UiWithFooter` 桥接。
|
|
10
|
+
* - factory 返回 FooterHandle(dispose/invalidate/render),render(width) 返回行数组。
|
|
11
|
+
*
|
|
12
|
+
* 单例限制:Pi 只有一个 footer 槽位,本扩展的 footer 会覆盖(或被覆盖)其他扩展
|
|
13
|
+
* (如 @zhushanwen/pi-statusline)的 footer。已知限制,README 注明。
|
|
14
|
+
*
|
|
15
|
+
* 可测试性:createPermissionFooter 是纯函数(输入 mode+enabled,返回 factory);
|
|
16
|
+
* renderPermissionLine 是纯函数(输入 mode+enabled+palette,返回行数组),便于单测。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PermissionMode } from "./types.js";
|
|
20
|
+
import { MODE_LABELS } from "./types.js";
|
|
21
|
+
|
|
22
|
+
// ──────────────────────── Footer API 适配类型 ────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Tui 句柄(Pi TUI 提供的渲染接口,最小子集)。 */
|
|
25
|
+
interface TuiHandle {
|
|
26
|
+
requestRender(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Footer 渲染句柄(setFooter 回调的返回值)。 */
|
|
30
|
+
export interface FooterHandle {
|
|
31
|
+
dispose(): void;
|
|
32
|
+
invalidate(): void;
|
|
33
|
+
render(width: number): string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Footer factory 的签名(setFooter 的回调参数类型)。 */
|
|
37
|
+
export type FooterFactory = (
|
|
38
|
+
tui: TuiHandle,
|
|
39
|
+
theme: unknown,
|
|
40
|
+
footerData: unknown,
|
|
41
|
+
) => FooterHandle;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* SDK 缺失的 setFooter 类型 —— 仅本扩展需要。
|
|
45
|
+
*
|
|
46
|
+
* 绕过 `as any`:用 `as unknown as` 明确意图,配合本地接口提供类型检查。
|
|
47
|
+
* SDK 补齐 setFooter 类型后可移除本接口。
|
|
48
|
+
*/
|
|
49
|
+
export interface UiWithFooter {
|
|
50
|
+
setFooter(fn: FooterFactory): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ──────────────────────── 渲染(纯函数) ────────────────────────
|
|
54
|
+
|
|
55
|
+
/** 语义色 token 到字符串着色器的映射(与 statusline 扩展的 Pallet 对齐)。 */
|
|
56
|
+
export interface PermissionPalette {
|
|
57
|
+
/** dim 文本(次要信息) */
|
|
58
|
+
dim(s: string): string;
|
|
59
|
+
/** 主文本 */
|
|
60
|
+
text(s: string): string;
|
|
61
|
+
/** accent(模式标签高亮) */
|
|
62
|
+
accent(s: string): string;
|
|
63
|
+
/** success(enabled 状态) */
|
|
64
|
+
success(s: string): string;
|
|
65
|
+
/** warning(disabled 状态) */
|
|
66
|
+
warning(s: string): string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 模式对应的 accent 色建议(调用方可参考,非强制)。 */
|
|
70
|
+
export const MODE_ACCENT_TOKEN: Record<PermissionMode, "accent" | "warning" | "success"> = {
|
|
71
|
+
yolo: "warning",
|
|
72
|
+
auto: "accent",
|
|
73
|
+
approve: "accent",
|
|
74
|
+
strict: "success",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 渲染权限状态行(纯函数,便于单测)。
|
|
79
|
+
*
|
|
80
|
+
* 输出格式:`[pi-permission] MODE · <enabled|disabled>`
|
|
81
|
+
* - mode 标签用 MODE_LABELS(YOLO/Auto/Approve/Strict)
|
|
82
|
+
* - enabled=true → success 色 "enabled"
|
|
83
|
+
* - enabled=false → warning 色 "disabled"(等同 yolo 但保留配置)
|
|
84
|
+
*
|
|
85
|
+
* yolo 模式额外提示 "all tools allowed",strict 提示 "all require approval"。
|
|
86
|
+
*
|
|
87
|
+
* @param mode 当前权限模式
|
|
88
|
+
* @param enabled 扩展是否启用
|
|
89
|
+
* @param palette 色彩映射(测试可传纯字符串透传的 palette)
|
|
90
|
+
* @returns 渲染行(单行,数组包装便于 footer render 统一接口)
|
|
91
|
+
*/
|
|
92
|
+
export function renderPermissionLine(
|
|
93
|
+
mode: PermissionMode,
|
|
94
|
+
enabled: boolean,
|
|
95
|
+
palette: PermissionPalette,
|
|
96
|
+
): string[] {
|
|
97
|
+
const label = MODE_LABELS[mode];
|
|
98
|
+
const statePart = enabled
|
|
99
|
+
? palette.success("enabled")
|
|
100
|
+
: palette.warning("disabled");
|
|
101
|
+
const detail = enabled ? modeDetail(mode) : "passthrough (config preserved)";
|
|
102
|
+
return [`${palette.dim("[pi-permission]")} ${palette.accent(label)} ${palette.dim("·")} ${statePart} ${palette.dim(detail)}`];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 各模式的简短补充说明(renderPermissionLine 用)。 */
|
|
106
|
+
function modeDetail(mode: PermissionMode): string {
|
|
107
|
+
switch (mode) {
|
|
108
|
+
case "yolo":
|
|
109
|
+
return "all tools allowed";
|
|
110
|
+
case "auto":
|
|
111
|
+
return "AST + rules + AI classifier";
|
|
112
|
+
case "approve":
|
|
113
|
+
return "rules + manual approval";
|
|
114
|
+
case "strict":
|
|
115
|
+
return "all require approval";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ──────────────────────── FooterHandle 实现 ────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 创建 footer factory(注册给 ctx.ui.setFooter)。
|
|
123
|
+
*
|
|
124
|
+
* factory 返回的 FooterHandle:
|
|
125
|
+
* - dispose():noop(无定时器/订阅需清理;mode 变化由 session_start 重建 factory)
|
|
126
|
+
* - invalidate():清渲染缓存
|
|
127
|
+
* - render(width):返回 renderPermissionLine 结果(缓存 + 截断到 width)
|
|
128
|
+
*
|
|
129
|
+
* @param getMode 返回当前 PermissionMode 的闭包(session 内 mode 可变,闭包读最新值)
|
|
130
|
+
* @param getEnabled 返回当前 enabled 状态的闭包
|
|
131
|
+
* @param palette 色彩映射(生产用 theme.fg 构造,测试用透传 palette)
|
|
132
|
+
*/
|
|
133
|
+
export function createPermissionFooter(
|
|
134
|
+
getMode: () => PermissionMode,
|
|
135
|
+
getEnabled: () => boolean,
|
|
136
|
+
palette: PermissionPalette,
|
|
137
|
+
): FooterFactory {
|
|
138
|
+
return (_tui: TuiHandle, _theme: unknown, _footerData: unknown): FooterHandle => {
|
|
139
|
+
let cachedWidth: number | undefined;
|
|
140
|
+
let cachedLines: string[] | undefined;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
dispose() {
|
|
144
|
+
/* noop — 无定时器/订阅 */
|
|
145
|
+
},
|
|
146
|
+
invalidate() {
|
|
147
|
+
cachedWidth = undefined;
|
|
148
|
+
cachedLines = undefined;
|
|
149
|
+
},
|
|
150
|
+
render(width: number): string[] {
|
|
151
|
+
if (cachedWidth === width && cachedLines) {
|
|
152
|
+
return cachedLines;
|
|
153
|
+
}
|
|
154
|
+
const lines = renderPermissionLine(getMode(), getEnabled(), palette);
|
|
155
|
+
cachedWidth = width;
|
|
156
|
+
cachedLines = lines;
|
|
157
|
+
return lines;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ──────────────────────── 生产 palette 构造 ────────────────────────
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 从 Pi Theme 构造 PermissionPalette(生产用)。
|
|
167
|
+
*
|
|
168
|
+
* theme.fg(token, text) 是 Pi 的语义着色 API(token 如 "dim"/"text"/"success")。
|
|
169
|
+
* 本地包装为 PermissionPalette 接口,便于 footer 与测试共用纯函数。
|
|
170
|
+
*
|
|
171
|
+
* @param theme Pi Theme 对象(ctx.ui.theme)
|
|
172
|
+
*/
|
|
173
|
+
export function paletteFromTheme(theme: { fg(token: string, text: string): string }): PermissionPalette {
|
|
174
|
+
return {
|
|
175
|
+
dim: (s) => theme.fg("dim", s),
|
|
176
|
+
text: (s) => theme.fg("text", s),
|
|
177
|
+
accent: (s) => theme.fg("accent", s),
|
|
178
|
+
success: (s) => theme.fg("success", s),
|
|
179
|
+
warning: (s) => theme.fg("warning", s),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 注册权限 footer 到 ctx.ui(生产入口)。
|
|
185
|
+
*
|
|
186
|
+
* 在 session_start handler 里调用。用 `ctx.ui as unknown as UiWithFooter` 桥接
|
|
187
|
+
* SDK 未声明的 setFooter 类型。
|
|
188
|
+
*
|
|
189
|
+
* 防御:headless(json/print)模式或 mock ctx 可能无 setFooter/theme —— 此时跳过
|
|
190
|
+
* footer 注册(footer 仅 TUI 有意义),返回 noop invalidator。用 duck typing 检查,不抛异常。
|
|
191
|
+
*
|
|
192
|
+
* 返回 invalidator:调用它清 footer 渲染缓存 + 触发重绘(mode/enabled 切换后 footer
|
|
193
|
+
* 才会显示新标签)。FooterHandle 由 Pi 在 factory 内部创建,扩展拿不到,故 invalidator
|
|
194
|
+
* 通过闭包捕获 factory 内创建的 handle + tui 句柄。
|
|
195
|
+
*
|
|
196
|
+
* @param ctxUi ctx.ui 对象(需含 setFooter + theme,缺失则跳过,返回 noop)
|
|
197
|
+
* @param getMode 返回当前 PermissionMode 的闭包
|
|
198
|
+
* @param getEnabled 返回当前 enabled 状态的闭包
|
|
199
|
+
* @returns invalidator 函数(mode 切换成功后调用,使 footer 立即反映新 mode)
|
|
200
|
+
*/
|
|
201
|
+
export function registerPermissionFooter(
|
|
202
|
+
ctxUi: { setFooter?: unknown; theme?: unknown },
|
|
203
|
+
getMode: () => PermissionMode,
|
|
204
|
+
getEnabled: () => boolean,
|
|
205
|
+
): () => void {
|
|
206
|
+
// duck typing:setFooter 必须是函数,theme 必须有 fg 函数(headless/mock 缺失则跳过)
|
|
207
|
+
if (typeof ctxUi.setFooter !== "function") return () => {};
|
|
208
|
+
const theme = ctxUi.theme as { fg?: unknown } | undefined;
|
|
209
|
+
if (!theme || typeof theme.fg !== "function") return () => {};
|
|
210
|
+
|
|
211
|
+
const palette = paletteFromTheme(theme as { fg(token: string, text: string): string });
|
|
212
|
+
// 闭包捕获:factory 被 Pi 调用时创建的 handle + tui,供 invalidator 刷缓存 + 重绘。
|
|
213
|
+
let handle: FooterHandle | undefined;
|
|
214
|
+
let tui: TuiHandle | undefined;
|
|
215
|
+
const factory = (t: TuiHandle, th: unknown, data: unknown): FooterHandle => {
|
|
216
|
+
tui = t;
|
|
217
|
+
const h = createPermissionFooter(getMode, getEnabled, palette)(t, th, data);
|
|
218
|
+
handle = h;
|
|
219
|
+
return h;
|
|
220
|
+
};
|
|
221
|
+
(ctxUi as unknown as UiWithFooter).setFooter(factory);
|
|
222
|
+
return () => {
|
|
223
|
+
handle?.invalidate();
|
|
224
|
+
tui?.requestRender();
|
|
225
|
+
};
|
|
226
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zhushanwen/pi-permission — 类型定义
|
|
3
|
+
*
|
|
4
|
+
* 全部类型统一在此声明,W2-W5 import 使用,避免跨模块类型碎片化。
|
|
5
|
+
* 对应 slice plan 的 DM1-DM5 数据模型。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ──────────────────────── DM1: PermissionMode ────────────────────────
|
|
9
|
+
|
|
10
|
+
/** 四档权限模式,按严格等级排序(yolo 最低,strict 最高) */
|
|
11
|
+
export type PermissionMode = "yolo" | "auto" | "approve" | "strict";
|
|
12
|
+
|
|
13
|
+
/** 模式枚举顺序(严格等级从低到高) */
|
|
14
|
+
export const PERMISSION_MODES: PermissionMode[] = ["yolo", "auto", "approve", "strict"];
|
|
15
|
+
|
|
16
|
+
/** 模式人类可读描述 */
|
|
17
|
+
export const MODE_DESCRIPTIONS: Record<PermissionMode, string> = {
|
|
18
|
+
yolo: "完全无防护,所有工具调用放行",
|
|
19
|
+
auto: "安全命令规则直接放行 + 非安全命令过 AI 审查 + AI 认为安全放行/AI 认为非安全人工审批",
|
|
20
|
+
approve: "自动模式去除 AI,规则匹配后安全放行、非安全直接人工审批",
|
|
21
|
+
strict: "全部审批",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** 模式简短标签(statusline / 命令输出用) */
|
|
25
|
+
export const MODE_LABELS: Record<PermissionMode, string> = {
|
|
26
|
+
yolo: "YOLO",
|
|
27
|
+
auto: "Auto",
|
|
28
|
+
approve: "Approve",
|
|
29
|
+
strict: "Strict",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** PermissionMode 类型守卫 */
|
|
33
|
+
export function isValidPermissionMode(value: unknown): value is PermissionMode {
|
|
34
|
+
return typeof value === "string" && PERMISSION_MODES.includes(value as PermissionMode);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ──────────────────────── DM2: Rule ────────────────────────
|
|
38
|
+
|
|
39
|
+
/** 权限规则(层 2 规则匹配用) */
|
|
40
|
+
export interface Rule {
|
|
41
|
+
/** 唯一 id(用户规则 'user-<n>',内置安全 'builtin-safe-<n>',内置危险 'builtin-danger-<n>') */
|
|
42
|
+
id: string;
|
|
43
|
+
/** 工具名匹配模式,wildcard('*' 匹配全部,'bash' 精确匹配) */
|
|
44
|
+
tool: string;
|
|
45
|
+
/** 命令匹配模式,wildcard(仅 bash 工具用,其他工具 undefined 或 '*') */
|
|
46
|
+
pattern: string;
|
|
47
|
+
/** 决策动作 */
|
|
48
|
+
action: PermissionAction;
|
|
49
|
+
/** 规则来源 */
|
|
50
|
+
source: RuleSource;
|
|
51
|
+
/** 可选描述(UI 展示用) */
|
|
52
|
+
description?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 权限决策动作(三态) */
|
|
56
|
+
export type PermissionAction = "allow" | "deny" | "ask";
|
|
57
|
+
|
|
58
|
+
/** 规则来源 */
|
|
59
|
+
export type RuleSource = "builtin-safe" | "builtin-danger" | "user";
|
|
60
|
+
|
|
61
|
+
// ──────────────────────── DM3: PermissionDecision ────────────────────────
|
|
62
|
+
|
|
63
|
+
/** 权限决策结果(checkPermission 返回) */
|
|
64
|
+
export interface PermissionDecision {
|
|
65
|
+
/** 最终动作 */
|
|
66
|
+
action: PermissionAction;
|
|
67
|
+
/** 人类可读原因(UI 展示 + 拒绝理由回传 agent) */
|
|
68
|
+
reason: string;
|
|
69
|
+
/** 决策来源(审计/调试用) */
|
|
70
|
+
source: DecisionSource;
|
|
71
|
+
/** AI 风险等级(仅 source='ai') */
|
|
72
|
+
riskLevel?: RiskLevel;
|
|
73
|
+
/** 匹配的规则(仅 source='rule') */
|
|
74
|
+
matchedRule?: Rule;
|
|
75
|
+
/** AI 置信度 0-1(仅 source='ai') */
|
|
76
|
+
confidence?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 决策来源 */
|
|
80
|
+
export type DecisionSource = "mode" | "ast" | "rule" | "ai" | "user";
|
|
81
|
+
|
|
82
|
+
/** AI 风险等级 */
|
|
83
|
+
export type RiskLevel = "low" | "medium" | "high";
|
|
84
|
+
|
|
85
|
+
// ──────────────────────── DM4: ClassifierConfig ────────────────────────
|
|
86
|
+
|
|
87
|
+
/** AI Classifier 配置(层 3 用,W4 实现) */
|
|
88
|
+
export interface ClassifierConfig {
|
|
89
|
+
/** 是否启用 AI 层(auto 模式自动 true,其他模式忽略) */
|
|
90
|
+
enabled: boolean;
|
|
91
|
+
/** 模型:'auto'(选最便宜)或 'provider/model-id'(如 'zhipu/glm-4-flash') */
|
|
92
|
+
model: string;
|
|
93
|
+
/** 超时秒数 */
|
|
94
|
+
timeout: number;
|
|
95
|
+
/** 低风险是否自动放行 */
|
|
96
|
+
autoApproveLowRisk: boolean;
|
|
97
|
+
/** 高风险是否自动拦截(转人工审批) */
|
|
98
|
+
autoDenyHighRisk: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ──────────────────────── DM5: PermissionConfig ────────────────────────
|
|
102
|
+
|
|
103
|
+
/** 扩展配置(~/.pi/agent/permission-config.json 持久化格式) */
|
|
104
|
+
export interface PermissionConfig {
|
|
105
|
+
/** 当前权限模式 */
|
|
106
|
+
mode: PermissionMode;
|
|
107
|
+
/** 扩展是否启用(false=完全放行,等同于 yolo 但保留配置) */
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
/** AI Classifier 配置 */
|
|
110
|
+
classifier: ClassifierConfig;
|
|
111
|
+
/** 用户自定义规则(内置规则在代码里硬编码,不进配置) */
|
|
112
|
+
userRules: Rule[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ──────────────────────── 默认配置 ────────────────────────
|
|
116
|
+
|
|
117
|
+
export const DEFAULT_CLASSIFIER_CONFIG: ClassifierConfig = {
|
|
118
|
+
enabled: true,
|
|
119
|
+
model: "auto",
|
|
120
|
+
timeout: 90,
|
|
121
|
+
autoApproveLowRisk: true,
|
|
122
|
+
autoDenyHighRisk: true,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const DEFAULT_CONFIG: PermissionConfig = {
|
|
126
|
+
mode: "yolo",
|
|
127
|
+
enabled: true,
|
|
128
|
+
classifier: { ...DEFAULT_CLASSIFIER_CONFIG },
|
|
129
|
+
userRules: [],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ──────────────────────── 后续 wave 用的类型(W1 声明,W2-W5 import) ────────────────────────
|
|
133
|
+
|
|
134
|
+
/** 权限检查输入(I1 checkPermission 参数) */
|
|
135
|
+
export interface PermissionCheckInput {
|
|
136
|
+
toolName: string;
|
|
137
|
+
command?: string;
|
|
138
|
+
path?: string;
|
|
139
|
+
toolInput?: unknown;
|
|
140
|
+
cwd: string;
|
|
141
|
+
agentName?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 权限上下文(I1 checkPermission 参数,含当前模式 + 规则 + classifier 配置) */
|
|
145
|
+
export interface PermissionContext {
|
|
146
|
+
mode: PermissionMode;
|
|
147
|
+
rules: Rule[];
|
|
148
|
+
classifier: ClassifierConfig;
|
|
149
|
+
enabled: boolean;
|
|
150
|
+
signal?: AbortSignal;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** bash 结构分析结果(I2 analyzeBashStructure 返回,W2 实现) */
|
|
154
|
+
export interface BashAnalysis {
|
|
155
|
+
/** 是否只有白名单结构(干净 SimpleCommand) */
|
|
156
|
+
clean: boolean;
|
|
157
|
+
/** 提取的命令 token 数组(每个元素是一条 SimpleCommand 的 argv) */
|
|
158
|
+
commands: string[][];
|
|
159
|
+
/** 检测到的危险结构节点类型(command_substitution/file_redirect/subshell 等) */
|
|
160
|
+
dangerousStructures: string[];
|
|
161
|
+
/** AST 解析是否失败(malformed 语法) */
|
|
162
|
+
parseError: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 规则匹配结果(I3 matchRules 返回,W3 实现) */
|
|
166
|
+
export interface RuleMatchResult {
|
|
167
|
+
action: PermissionAction;
|
|
168
|
+
matchedRule?: Rule;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** AI 分类结果(I4 classifyRisk 返回,W4 实现) */
|
|
172
|
+
export interface ClassifierResult {
|
|
173
|
+
risk_level: RiskLevel;
|
|
174
|
+
outcome: PermissionAction;
|
|
175
|
+
reasoning: string;
|
|
176
|
+
confidence: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** AI 分类器的工具调用上下文(I4 输入) */
|
|
180
|
+
export interface ToolInvocationContext {
|
|
181
|
+
toolName: string;
|
|
182
|
+
command?: string;
|
|
183
|
+
path?: string;
|
|
184
|
+
cwd: string;
|
|
185
|
+
agentName?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 用户审批决策(I5 Racing 的 showUserDialog 返回,W5 实现) */
|
|
189
|
+
export interface UserDecision {
|
|
190
|
+
approved: boolean;
|
|
191
|
+
reason?: string;
|
|
192
|
+
scope?: "once" | "session" | "always";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ──────────────────────── W6 T8: 从 pipeline.ts 迁移的类型 ────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 用户审批 UI 需要的数据(runLayer3WithRacing / approve / strict 传给 requestUserApproval)。
|
|
199
|
+
*
|
|
200
|
+
* reason 是人类可读的「为什么要审批」(含工具名 + 命令 + 触发原因),由 buildApprovalRequest 构造。
|
|
201
|
+
* preClassification 仅 auto 模式 AI 先返回时携带(让用户看到 AI 的判断,辅助决策)。
|
|
202
|
+
*
|
|
203
|
+
* W6 T8:从 pipeline.ts 迁移到 types.ts(统一类型声明),pipeline.ts re-export 保持 public API。
|
|
204
|
+
*/
|
|
205
|
+
export interface ApprovalRequest {
|
|
206
|
+
toolName: string;
|
|
207
|
+
command?: string;
|
|
208
|
+
reason: string;
|
|
209
|
+
preClassification?: ClassifierResult;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* checkPermission 的外部依赖(DI 便于测试 mock)。
|
|
214
|
+
*
|
|
215
|
+
* - analyzeBashStructure:W2 AST 分析(层 1)。
|
|
216
|
+
* - matchRulesForArgv:W3 规则匹配(层 2,bash happy path)。
|
|
217
|
+
* - getDefaultRules:W3 内置危险规则(12 条 builtin-danger)。
|
|
218
|
+
* - classifier.classifyRisk:W4 AI 风险分类(层 3)。
|
|
219
|
+
* - requestUserApproval:W5 用户审批 UI(TUI/RPC/headless 三分支)。
|
|
220
|
+
*
|
|
221
|
+
* 生产装配见 production.ts;测试 mock 见 pipeline.test.ts。
|
|
222
|
+
*
|
|
223
|
+
* W6 T8:从 pipeline.ts 迁移到 types.ts(统一类型声明),pipeline.ts re-export 保持 public API。
|
|
224
|
+
*/
|
|
225
|
+
export interface CheckPermissionDeps {
|
|
226
|
+
analyzeBashStructure: (command: string) => Promise<BashAnalysis>;
|
|
227
|
+
matchRulesForArgv: (argv: string[], rules: readonly Rule[]) => RuleMatchResult;
|
|
228
|
+
getDefaultRules: () => Rule[];
|
|
229
|
+
/**
|
|
230
|
+
* 是否处于 headless 模式(json/print,无交互 UI)。
|
|
231
|
+
*
|
|
232
|
+
* auto 模式 Racing 用此判断:headless 时不启动 user promise(立即 deny 会抢占 AI 的 race),
|
|
233
|
+
* 纯等 AI classifier 判定;AI 失败/超时则 fail-closed deny。
|
|
234
|
+
* strict/approve 不受影响——它们的 askUser 走 requestHeadless 立即 deny(无 AI 可兜底)。
|
|
235
|
+
*/
|
|
236
|
+
isHeadless: () => boolean;
|
|
237
|
+
classifier: {
|
|
238
|
+
classifyRisk: (
|
|
239
|
+
ctx: ToolInvocationContext,
|
|
240
|
+
config: ClassifierConfig,
|
|
241
|
+
signal?: AbortSignal,
|
|
242
|
+
) => Promise<ClassifierResult>;
|
|
243
|
+
};
|
|
244
|
+
requestUserApproval: (
|
|
245
|
+
req: ApprovalRequest,
|
|
246
|
+
ctx: ToolInvocationContext,
|
|
247
|
+
signal: AbortSignal | undefined,
|
|
248
|
+
) => Promise<UserDecision>;
|
|
249
|
+
}
|