@sidleo3/dsh-chat-feishu 0.0.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/client/index.js +1190 -0
- package/cordis.patch.yml +5 -0
- package/host/bridge.mjs +1605 -0
- package/host/config-store.mjs +240 -0
- package/host/controller.mjs +1514 -0
- package/host/index.mjs +227 -0
- package/host/lark-cli.mjs +543 -0
- package/host/lark-gateway.mjs +1362 -0
- package/host/lark-guard.mjs +200 -0
- package/host/panel-card.mjs +681 -0
- package/host/provision.mjs +247 -0
- package/host/state-store.mjs +160 -0
- package/host/turn-presenter.mjs +778 -0
- package/lib/client.js +1132 -0
- package/lib/index.js +132126 -0
- package/package.json +54 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 聊天会话里的 lark-cli 门禁:让「身份策略」真的生效,而不是只写进配置。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(真机踩过):机器人在飞书里回答"以我的身份发一条消息"时,
|
|
5
|
+
* 模型是用 `lark-cli` 的 skill + bash **直接**跑的:
|
|
6
|
+
*
|
|
7
|
+
* lark-cli im +messages-send --as user --user-id ou_… --text 测试
|
|
8
|
+
*
|
|
9
|
+
* 这条路径根本不经过本插件的 `host/lark-cli.mjs`(那是插件自己调 lark-cli 的入口),
|
|
10
|
+
* 于是设置页里那个「只用应用身份」的开关**对模型的实际调用没有任何约束力**——
|
|
11
|
+
* 真机上第二条消息照样以用户身份发了出去(会话导出里写得很清楚:`identity: "user"`)。
|
|
12
|
+
*
|
|
13
|
+
* 所以这里在工具执行前拦一道(DSH 的 `tools/pre-execute` 瀑布事件):
|
|
14
|
+
* **只要这个会话属于某台飞书机器人**,它跑的每一条 lark-cli 命令都必须
|
|
15
|
+
* ① 绑定这台机器人自己的 profile、② 显式写清身份(`--as bot` / `--as user`)——
|
|
16
|
+
* 省略身份时 lark-cli 会自己"看着办"(这台机器上 `auto` 会挑 user,照样是误用);
|
|
17
|
+
* 策略是「只用应用身份」时再禁用 `--as user`。别处(用户自己的 DSH 会话)一概不管。
|
|
18
|
+
*
|
|
19
|
+
* 另外挡掉三条会改**本机全局状态**、影响这台机器上别人的命令:
|
|
20
|
+
* `profile use`、`config strict-mode --global`、以及 `--use`(建 profile 时顺手切换生效 profile)。
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-chat-feishu/lark-guard
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** 只有模型跑 shell 的工具才需要看门(run_code 之类的复合工具另说,见 README 的已知边界)。 */
|
|
26
|
+
const SHELL_TOOLS = Object.freeze(['bash', 'pwsh']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 不碰租户 API、也不需要身份的子命令:它们读的是本机配置或文档,放行。
|
|
30
|
+
*
|
|
31
|
+
* `whoami` 在这里:它就是"告诉我现在是谁",配上 profile 反而是最该允许的调用。
|
|
32
|
+
*/
|
|
33
|
+
const LOCAL_SUBCOMMANDS = Object.freeze([
|
|
34
|
+
'profile', 'config', 'whoami', 'skills', 'schema', 'doctor', 'update', 'help', 'version',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** 会改本机/全局状态的写法(在机器人会话里一律拒绝)。 */
|
|
38
|
+
const BANNED_PATTERNS = Object.freeze([
|
|
39
|
+
{ pattern: /--use(?![\w-])/, message: '不许带 --use:它会切换这台机器上 lark-cli 的"当前生效 profile",影响别人的用法' },
|
|
40
|
+
{ pattern: /--global(?![\w-])/, message: '不许带 --global:那是把策略写到全局,会影响这台机器上所有应用' },
|
|
41
|
+
{ pattern: /profile\s+use(?![\w-])/, message: '不许执行 `profile use`:它是全局开关,会影响这台机器上所有应用' },
|
|
42
|
+
{ pattern: /config\s+bind(?![\w-])/, message: '不许执行 `config bind`:那会把 lark-cli 绑到别的 agent 上下文上' },
|
|
43
|
+
{ pattern: /config\s+remove(?![\w-])/, message: '不许执行 `config remove`:它会清掉应用配置与令牌' },
|
|
44
|
+
{ pattern: /auth\s+logout(?![\w-])/, message: '不许执行 `auth logout`:它会注销这台机器上的登录态' },
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** 取 flag 值时要跳过"值本身",否则会被误当成子命令。 */
|
|
48
|
+
const VALUE_FLAGS = Object.freeze(['--profile', '--as', '--format', '--jq', '-q', '--domain', '--scope', '--output', '--output-dir']);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 按 shell 分隔符把一条命令切成若干段(引号内的分隔符不解析——这里是绊线,不是 shell)。
|
|
52
|
+
*
|
|
53
|
+
* @param command - 模型给的命令字符串。
|
|
54
|
+
* @returns 命令段数组。
|
|
55
|
+
*/
|
|
56
|
+
export function splitCommandSegments(command) {
|
|
57
|
+
return String(command ?? '').split(/&&|\|\||;|\||\n/);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 这一段是不是在**执行** lark-cli。
|
|
62
|
+
*
|
|
63
|
+
* 为什么不是"字符串里出现 lark-cli 就算":`grep -rn lark-cli docs/`、`cat lark-cli.md`
|
|
64
|
+
* 这类**提到**它的命令到处都是,按字符串匹配会把正常的读文档/搜索也拦下来。
|
|
65
|
+
* 所以只认"命令位置":段首(可带 `VAR=x` 赋值与 sudo/env 之类的包装)、
|
|
66
|
+
* 或 `$(…)` / 反引号里的开头;路径写法 `/usr/local/bin/lark-cli` 也算。
|
|
67
|
+
*
|
|
68
|
+
* @param segment - 命令段。
|
|
69
|
+
* @returns 是否在执行 lark-cli。
|
|
70
|
+
*/
|
|
71
|
+
export function segmentRunsLarkCli(segment) {
|
|
72
|
+
const wrappers = new Set(['sudo', 'env', 'time', 'command', 'nohup', 'nice', 'caffeinate']);
|
|
73
|
+
for (const part of String(segment ?? '').split(/\$\(|`/)) {
|
|
74
|
+
const tokens = part.trim().split(/\s+/).filter(Boolean);
|
|
75
|
+
let index = 0;
|
|
76
|
+
while (index < tokens.length
|
|
77
|
+
&& (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index]) || wrappers.has(tokens[index]))) index += 1;
|
|
78
|
+
if (index >= tokens.length) continue;
|
|
79
|
+
const head = tokens[index].replace(/^[({!]+/, '');
|
|
80
|
+
if (head === 'lark-cli' || /(^|\/)lark-cli$/.test(head)) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 取某个 flag 的全部取值(同时认 `--flag value` 与 `--flag=value`)。 */
|
|
86
|
+
function flagValues(text, flag) {
|
|
87
|
+
const values = [];
|
|
88
|
+
const pattern = new RegExp(`${flag}[=\\s]+("[^"]*"|'[^']*'|\\S+)`, 'g');
|
|
89
|
+
for (const match of String(text).matchAll(pattern)) {
|
|
90
|
+
values.push(match[1].replace(/^["']|["']$/g, ''));
|
|
91
|
+
}
|
|
92
|
+
return values;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 这段命令是不是"本地子命令"(不需要身份,也不碰租户 API)。
|
|
97
|
+
*
|
|
98
|
+
* @param segment - 命令段。
|
|
99
|
+
* @returns 是否本地。
|
|
100
|
+
*/
|
|
101
|
+
export function isLocalCommand(segment) {
|
|
102
|
+
const text = String(segment ?? '');
|
|
103
|
+
if (/(^|\s)(--help|-h|--version)(\s|$)/.test(text)) return true;
|
|
104
|
+
const tokens = text.split(/\s+/).filter(Boolean);
|
|
105
|
+
const start = tokens.findIndex((token) => /(^|\/)lark-cli$/.test(token.replace(/^[({!]+/, '')));
|
|
106
|
+
if (start < 0) return false;
|
|
107
|
+
for (let index = start + 1; index < tokens.length; index += 1) {
|
|
108
|
+
const token = tokens[index];
|
|
109
|
+
if (VALUE_FLAGS.includes(token)) {
|
|
110
|
+
index += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (VALUE_FLAGS.some((flag) => token.startsWith(`${flag}=`))) continue;
|
|
114
|
+
if (token.startsWith('-')) continue;
|
|
115
|
+
const head = token.replace(/[^\w-]/g, '');
|
|
116
|
+
// `event consume`:订阅事件同样要显式身份(它不是本地命令)。
|
|
117
|
+
return LOCAL_SUBCOMMANDS.includes(head);
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 检查一段 lark-cli 命令是否符合本机器人的身份策略。
|
|
124
|
+
*
|
|
125
|
+
* @param options - { segment, profileName, mode }:mode 为 'bot-only' | 'user-allowed'。
|
|
126
|
+
* @returns 拒绝原因;合规时返回 null。
|
|
127
|
+
*/
|
|
128
|
+
export function evaluateLarkSegment({ segment, profileName, mode }) {
|
|
129
|
+
const text = String(segment ?? '');
|
|
130
|
+
for (const banned of BANNED_PATTERNS) {
|
|
131
|
+
if (banned.pattern.test(text)) {
|
|
132
|
+
return `这台机器人的会话里${banned.message}。请改用:\`lark-cli --profile ${profileName} <子命令> --as bot\`。`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!flagValues(text, '--profile').includes(profileName)) {
|
|
136
|
+
return '这条 lark-cli 命令没有绑定本机器人在 lark-cli 里的 profile。'
|
|
137
|
+
+ '不带 profile 时 lark-cli 会用这台机器上"当前生效"的那份授权——那可能是别的应用、'
|
|
138
|
+
+ `甚至别人的账号。请写成:\`lark-cli --profile ${profileName} <子命令> --as bot\`。`;
|
|
139
|
+
}
|
|
140
|
+
if (isLocalCommand(text)) return null;
|
|
141
|
+
const identities = flagValues(text, '--as');
|
|
142
|
+
if (identities.length === 0) {
|
|
143
|
+
return '这条 lark-cli 命令没有显式写身份,lark-cli 会自己挑(这台机器上会挑成用户身份)。'
|
|
144
|
+
+ `请显式加上 \`--as bot\`(代表这台应用自己);${
|
|
145
|
+
mode === 'user-allowed'
|
|
146
|
+
? '要以某个人的身份操作时才用 `--as user`。'
|
|
147
|
+
: '本机器人只允许应用身份,`--as user` 会被拒绝。'}`;
|
|
148
|
+
}
|
|
149
|
+
if (mode !== 'user-allowed' && identities.includes('user')) {
|
|
150
|
+
return '本机器人的 lark-cli 身份策略是「只用应用身份」,这次调用用了 `--as user`,已拒绝。'
|
|
151
|
+
+ '请改用 `--as bot`;要允许用户身份,到设置页 → 这台机器人 → 「lark-cli 身份」里开启(需要二次确认)。';
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 造一个门禁:接在 DSH 的 `tools/pre-execute` 瀑布上。
|
|
158
|
+
*
|
|
159
|
+
* @param options - 依赖:
|
|
160
|
+
* - `locate(sessionId)`:这个会话属于哪个 (渠道, 机器人, 会话键)(hub 的会话绑定表);
|
|
161
|
+
* - `policyFor(botId)`:该机器人的 lark-cli 身份策略 `{ mode, profileName }`(拿不到返回 null);
|
|
162
|
+
* - `channelId`:只管本渠道的会话(别的渠道的会话一概不管);
|
|
163
|
+
* - `logger`:拒绝要留日志(静默拦截是最难查的故障形态)。
|
|
164
|
+
* @returns `{ evaluate(exec) }`:返回 null = 放行,否则返回 `{ kind:'deny', reason }`。
|
|
165
|
+
* 两个依赖都可以是异步的(读取设置前要先 await 落盘文档就绪)。
|
|
166
|
+
*/
|
|
167
|
+
export function createLarkCliGuard({ locate, policyFor, channelId, logger = console } = {}) {
|
|
168
|
+
async function evaluate(exec) {
|
|
169
|
+
if (typeof locate !== 'function' || typeof policyFor !== 'function') return null;
|
|
170
|
+
const toolName = typeof exec?.name === 'string' ? exec.name : '';
|
|
171
|
+
if (!SHELL_TOOLS.includes(toolName)) return null;
|
|
172
|
+
const args = exec?.arguments;
|
|
173
|
+
const command = typeof args?.command === 'string'
|
|
174
|
+
? args.command
|
|
175
|
+
: typeof args?.script === 'string' ? args.script : null;
|
|
176
|
+
if (!command || !segmentRunsLarkCli(command)) return null;
|
|
177
|
+
const sessionId = exec?.agent?.session?.header?.id;
|
|
178
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
179
|
+
const owner = await locate(sessionId);
|
|
180
|
+
if (!owner || (channelId !== undefined && owner.channelId !== channelId)) return null;
|
|
181
|
+
const policy = await policyFor(owner.botId);
|
|
182
|
+
if (!policy?.profileName) return null;
|
|
183
|
+
for (const segment of splitCommandSegments(command)) {
|
|
184
|
+
if (!segmentRunsLarkCli(segment)) continue;
|
|
185
|
+
const reason = evaluateLarkSegment({
|
|
186
|
+
segment,
|
|
187
|
+
profileName: policy.profileName,
|
|
188
|
+
mode: policy.mode,
|
|
189
|
+
});
|
|
190
|
+
if (reason) {
|
|
191
|
+
logger.warn?.(`[dsh-chat-feishu] 拦下一条 lark-cli 调用(${owner.botId} / 会话 ${sessionId}):`
|
|
192
|
+
+ `${reason}|命令:${segment.trim().slice(0, 200)}`);
|
|
193
|
+
return { kind: 'deny', reason, botId: owner.botId };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { evaluate };
|
|
200
|
+
}
|