概述
对 v2.30.0(1228ac2)做代码审查时,在 core/plugin/、core/telemetry/、core/db/、core/sticker_manager.py 发现以下缺陷。
先说好的部分:数据库层全部走 SQLAlchemy 表达式(无注入面),三处解压路径都正确接入了 is_within_directory 的 zip-slip 防御,也没有 eval/exec/pickle/yaml.load 之类的危险反序列化。下面的问题主要集中在生命周期清理与遥测一致性。
1. DefaultChatPlugin.terminate() 空实现,去抖后台任务与按会话字典在禁用/重载后泄漏(Medium)
位置:core/plugin/builtin_plugins/chat/main.py:29-33
async def terminate(self):
"""
Cleanup when plugin is terminated
"""
pass
实际行为:handle_msg 会为每个会话创建 self.session_tasks[sid] = asyncio.create_task(self._debounce_loop(sid))(:70),而 _debounce_loop 是 while True 常驻循环。terminate() 什么都不做,因此插件被禁用/重载、或 update_plugin_config 触发重初始化时:
- 这些任务不会被取消,会继续持有旧的
self.ctx 运行,仍在向 message_processor flush,可能对已过期的上下文产生副作用
session_tasks/session_events 字典也从不清理,随会话数无上限增长
复现:反复"禁用 → 启用" Default Chat 插件,或多次修改其配置,僵尸去抖任务会不断堆积。
期望行为:terminate() 取消所有 session_tasks 并清空两个字典。
2. 前台 exec 超时只杀 shell 不杀子进程组(Medium)
位置:core/plugin/builtin_plugins/file/main.py:150-155
result = subprocess.run(
shell_command, shell=True, capture_output=True,
stdin=subprocess.DEVNULL,
timeout=exec_timeout, env=env, cwd=exec_work_dir,
encoding='utf-8', errors='replace'
)
实际行为:前台执行路径没有创建独立进程组。命令超时时 subprocess.run 只终止顶层 shell,其派生的子/孙进程会成为孤儿继续运行。LLM 触发一个会 fork 长驻子进程的命令并超时后,子进程泄漏、继续占用 CPU/端口/文件句柄。
对比后台路径处理是正确的::227 设置了 start_new_session=True(Windows 走 :225 的 CREATE_NEW_PROCESS_GROUP)并配合 killpg。前台路径缺失同等清理。
期望行为:前台路径同样创建进程组并在超时时 killpg。
3. 遥测每 30 分钟重新聚合会重复入队未标记记录,导致服务端重复计数(Medium)
位置:core/telemetry/client.py:469、:486、:350-389
msg_rows = await self.db.get_unreported_telemetry_message_rows(since_ts)
...
self.send_event(TelemetryEventType.MESSAGE_STATS, {...},
on_success=lambda ids=ids: self.db.mark_telemetry_messages_by_ids(ids))
实际行为:记录只有在事件被 worker 成功发送后(on_success 回调)才置 reported=True,而 _report_stats 每 30 分钟(以及 shutdown() 里再调一次,:138)会重新查询所有 reported=False 的行并重新入队。
当遥测服务端不可达或 worker 积压超过 30 分钟时,同一批未标记行会被反复构造成新事件塞进队列;网络恢复后 worker 会把这些重复事件全部发出(mark_*_by_ids 幂等,但事件本身已重复),服务端对同一小时/同一模型的消息数、token 数出现重复累加。shutdown 紧接调度运行之后调用 _report_stats 时几乎必然产生一次重复入队。
期望行为:入队时即标记"在途"状态,或用队列内去重避免同一批 id 被重复构造。
4. _report_lock 依据 .locked() 释放,可能释放非本协程持有的锁(Medium)
位置:core/telemetry/client.py:455-456、:460、:555-556
finally:
# Guarantee the lock is released if this task is cancelled while inside _report_stats
if self._report_lock.locked():
self._report_lock.release()
实际行为:asyncio.Lock 不记录持有者,release() 只看"是否被锁"。_stats_loop 的 finally 与 _report_stats 的 finally 都用 if locked(): release()。若 _stats_loop 在取消清理时锁正被另一处(如 shutdown 直接调用的 _report_stats)持有,这里会把别人的锁释放掉,破坏互斥。
当前 shutdown 会先取消并 await 掉 _stats_task 再自己调 _report_stats,使窗口较窄,但这是脆弱写法,容易在后续改动中被触发。
期望行为:各自记录"本次调用是否 acquire 成功"再决定释放。
5. restricted_paths 用子串匹配,正常文件名被误拦(Low)
位置:core/plugin/builtin_plugins/file/main.py:21-22、:375-377
'key' in path 会让 data/files/monkey.txt("monkey")被判为受限。同理 secret/token 等关键词也会误伤。功能性误伤,非安全问题,但会让合法读写莫名被拒。
期望行为:按路径段/文件名精确匹配,而非裸子串。
6. 其他 Low 级观察
core/plugin/plugin_registry.py:2086-2096:load_plugin_from_dir 在"未找到 BasePlugin 子类"分支直接 return None,但模块已 exec_module 成功并写入 sys.modules,装饰器注册进 _plugin_components 的内容也残留。后续 _cleanup_plugin_modules 能靠路径匹配兜底,但组件残留仍在。
core/plugin/plugin_registry.py:279-338、:347:get_obj_plugin_id 在无法解析归属时返回 "",_ensure_components("") 会把不同来源的工具/hook 混入同一空 id 桶;而 is_plugin_enabled("") 恒为 False(:652-653),行为不一致。
core/db/db_mgr.py:89-97:DatabaseManager.execute() 在 async with get_session() 退出(会话已关闭)后返回未物化的 Result。当前无调用方(fetch_one/fetch_all 在块内已 .all() 物化所以安全),属潜在坑,建议移除或改为块内消费。
core/db/service.py:505-516:mark_telemetry_reported 按时间窗全量标记 reported.is_(False),正是 mark_*_by_ids 系列想避免的竞态(聚合后新插入的当前小时行会被误标)。当前未见调用,建议删除或标注弃用。
core/sticker_manager.py:101:asyncio.create_task(self._fire_registered(...)) 未保留引用,可能被 GC 回收导致 VLM 描述回调中途丢失;:93-94 的 _sticker_index += 1 无锁,并发注册可能产生重复 id。
core/telemetry/client.py:399-403:国家码查询走明文 HTTP(http://ip-api.com/json/?fields=countryCode),会把客户端公网 IP 以未加密方式暴露给第三方及链路观察者。响应本身不敏感,但建议改 HTTPS。
core/plugin/builtin_plugins/search/main.py:57、:88:tavily_search 的 topic 类型标注含 "finance" 但 schema enum 只有 general/news;tavily_extract 向 client.extract(...) 传了 max_results,该接口未必接受此参数。
补充:测试覆盖
core/plugin/plugin_registry.py(2132 行,本模块最核心)几乎无直接测试 —— load_plugin_from_dir/init_plugin/terminate/reload/uninstall_plugin 的加载-卸载-重载、hook/tool/tag 的注册与清理(_cleanup_plugin_registration)、_cleanup_plugin_modules 的 sys.modules 清理、_remove_plugin_routes 的路由回收均无覆盖(仅 test_plugin_widget_lifecycle.py 覆盖 widget 声明保留一项)。
另外三处 zip-slip 防御分支(_extract_and_install、path_utils.safe_extract_zip、dist_checker._extract_dist)都没有构造 ../ 成员的拒绝测试;file 插件的 _normalize_path/_is_path_allowed/read_file/write_file/edit_file 越权与扩展名拒绝逻辑也无测试;core/telemetry/client.py(556 行)的聚合/去重/on_success 标记/锁处理无测试。
KiraAI version: v2.30.0(1228ac2)
Environment: 静态代码审查,非特定 OS/Python 版本相关
概述
对
v2.30.0(1228ac2)做代码审查时,在core/plugin/、core/telemetry/、core/db/、core/sticker_manager.py发现以下缺陷。先说好的部分:数据库层全部走 SQLAlchemy 表达式(无注入面),三处解压路径都正确接入了
is_within_directory的 zip-slip 防御,也没有eval/exec/pickle/yaml.load之类的危险反序列化。下面的问题主要集中在生命周期清理与遥测一致性。1.
DefaultChatPlugin.terminate()空实现,去抖后台任务与按会话字典在禁用/重载后泄漏(Medium)位置:
core/plugin/builtin_plugins/chat/main.py:29-33实际行为:
handle_msg会为每个会话创建self.session_tasks[sid] = asyncio.create_task(self._debounce_loop(sid))(:70),而_debounce_loop是while True常驻循环。terminate()什么都不做,因此插件被禁用/重载、或update_plugin_config触发重初始化时:self.ctx运行,仍在向message_processorflush,可能对已过期的上下文产生副作用session_tasks/session_events字典也从不清理,随会话数无上限增长复现:反复"禁用 → 启用" Default Chat 插件,或多次修改其配置,僵尸去抖任务会不断堆积。
期望行为:
terminate()取消所有session_tasks并清空两个字典。2. 前台
exec超时只杀 shell 不杀子进程组(Medium)位置:
core/plugin/builtin_plugins/file/main.py:150-155实际行为:前台执行路径没有创建独立进程组。命令超时时
subprocess.run只终止顶层 shell,其派生的子/孙进程会成为孤儿继续运行。LLM 触发一个会 fork 长驻子进程的命令并超时后,子进程泄漏、继续占用 CPU/端口/文件句柄。对比后台路径处理是正确的:
:227设置了start_new_session=True(Windows 走:225的CREATE_NEW_PROCESS_GROUP)并配合killpg。前台路径缺失同等清理。期望行为:前台路径同样创建进程组并在超时时
killpg。3. 遥测每 30 分钟重新聚合会重复入队未标记记录,导致服务端重复计数(Medium)
位置:
core/telemetry/client.py:469、:486、:350-389实际行为:记录只有在事件被 worker 成功发送后(
on_success回调)才置reported=True,而_report_stats每 30 分钟(以及shutdown()里再调一次,:138)会重新查询所有reported=False的行并重新入队。当遥测服务端不可达或 worker 积压超过 30 分钟时,同一批未标记行会被反复构造成新事件塞进队列;网络恢复后 worker 会把这些重复事件全部发出(
mark_*_by_ids幂等,但事件本身已重复),服务端对同一小时/同一模型的消息数、token 数出现重复累加。shutdown紧接调度运行之后调用_report_stats时几乎必然产生一次重复入队。期望行为:入队时即标记"在途"状态,或用队列内去重避免同一批 id 被重复构造。
4.
_report_lock依据.locked()释放,可能释放非本协程持有的锁(Medium)位置:
core/telemetry/client.py:455-456、:460、:555-556实际行为:
asyncio.Lock不记录持有者,release()只看"是否被锁"。_stats_loop的 finally 与_report_stats的 finally 都用if locked(): release()。若_stats_loop在取消清理时锁正被另一处(如shutdown直接调用的_report_stats)持有,这里会把别人的锁释放掉,破坏互斥。当前
shutdown会先取消并 await 掉_stats_task再自己调_report_stats,使窗口较窄,但这是脆弱写法,容易在后续改动中被触发。期望行为:各自记录"本次调用是否 acquire 成功"再决定释放。
5.
restricted_paths用子串匹配,正常文件名被误拦(Low)位置:
core/plugin/builtin_plugins/file/main.py:21-22、:375-377'key' in path会让data/files/monkey.txt("monkey")被判为受限。同理secret/token等关键词也会误伤。功能性误伤,非安全问题,但会让合法读写莫名被拒。期望行为:按路径段/文件名精确匹配,而非裸子串。
6. 其他 Low 级观察
core/plugin/plugin_registry.py:2086-2096:load_plugin_from_dir在"未找到 BasePlugin 子类"分支直接return None,但模块已exec_module成功并写入sys.modules,装饰器注册进_plugin_components的内容也残留。后续_cleanup_plugin_modules能靠路径匹配兜底,但组件残留仍在。core/plugin/plugin_registry.py:279-338、:347:get_obj_plugin_id在无法解析归属时返回"",_ensure_components("")会把不同来源的工具/hook 混入同一空 id 桶;而is_plugin_enabled("")恒为False(:652-653),行为不一致。core/db/db_mgr.py:89-97:DatabaseManager.execute()在async with get_session()退出(会话已关闭)后返回未物化的Result。当前无调用方(fetch_one/fetch_all在块内已.all()物化所以安全),属潜在坑,建议移除或改为块内消费。core/db/service.py:505-516:mark_telemetry_reported按时间窗全量标记reported.is_(False),正是mark_*_by_ids系列想避免的竞态(聚合后新插入的当前小时行会被误标)。当前未见调用,建议删除或标注弃用。core/sticker_manager.py:101:asyncio.create_task(self._fire_registered(...))未保留引用,可能被 GC 回收导致 VLM 描述回调中途丢失;:93-94的_sticker_index += 1无锁,并发注册可能产生重复 id。core/telemetry/client.py:399-403:国家码查询走明文 HTTP(http://ip-api.com/json/?fields=countryCode),会把客户端公网 IP 以未加密方式暴露给第三方及链路观察者。响应本身不敏感,但建议改 HTTPS。core/plugin/builtin_plugins/search/main.py:57、:88:tavily_search的topic类型标注含"finance"但 schema enum 只有general/news;tavily_extract向client.extract(...)传了max_results,该接口未必接受此参数。补充:测试覆盖
core/plugin/plugin_registry.py(2132 行,本模块最核心)几乎无直接测试 ——load_plugin_from_dir/init_plugin/terminate/reload/uninstall_plugin的加载-卸载-重载、hook/tool/tag 的注册与清理(_cleanup_plugin_registration)、_cleanup_plugin_modules的sys.modules清理、_remove_plugin_routes的路由回收均无覆盖(仅test_plugin_widget_lifecycle.py覆盖 widget 声明保留一项)。另外三处 zip-slip 防御分支(
_extract_and_install、path_utils.safe_extract_zip、dist_checker._extract_dist)都没有构造../成员的拒绝测试;file插件的_normalize_path/_is_path_allowed/read_file/write_file/edit_file越权与扩展名拒绝逻辑也无测试;core/telemetry/client.py(556 行)的聚合/去重/on_success标记/锁处理无测试。KiraAI version:
v2.30.0(1228ac2)Environment: 静态代码审查,非特定 OS/Python 版本相关