fix(isolation): 通用助手会话泄漏产线项目工具——5层修复

根因:
1. tool_registry 全局单例跨会话污染:产线会话注册的工具(如 list_projects)
   永久残留,后续通用会话把全部工具塞给 LLM
2. generic 会话 pipeline_id 空被无条件回退 DEFAULT_ABILITY_ID(sdlc_general),
   _execute_ability_tool 拿到产线能力包直接执行
3. _h_list_projects 对 space='general' 不过滤,列出所有产线全部项目
4. GENERAL_TOOLS 本身含项目管理工具,通用会话无条件拿到

修复(纵深防御,schema 层+执行层双保险):
- agent_loop_v2: ToolRegistry 改每 executor 独立实例(禁全局单例)
- agent_loop_v2: generic 会话跳过默认产线回退
- agent_loop_v2: _execute_ability_tool 对 generic 直接返回 None
- agent_loop_v2: _dispatch_sdlc_tool 对 generic 拒绝项目类工具(含别名归一)
- agent_loop_v2: generic 记忆只读 global 域、技能只检索 global 层
- sdlc_ability: _h_list_projects 对 general 空间拒绝列项目

配套:pipeline-core load_agent_config generic 时剔除 category=project 工具
This commit is contained in:
ymq 2026-09-05 12:43:33 +08:00
parent e88e0bfc37
commit b99fb4cc13
2 changed files with 60 additions and 26 deletions

View File

@ -46,6 +46,15 @@ _PROJECT_TOOL_ALIASES = {
from .workspace import WORKSPACE_BASE, GENERAL_SPACE
# 产线/项目管理类工具category=='project')。产线隔离铁律:纯通用会话
# (无产线插件)绝不挂载——否则通用助手能看到并操作其他产线的项目
# 2026-09-05 修复)。过滤同时作用于:① schemaLLM 看不见)
# ② 执行层(看见也拒绝),纵深防御。
_PROJECT_TOOL_NAMES = {
"switch_project", "create_project", "project_info", "list_my_projects",
"pause_project", "resume_project", "delete_project",
}
# ── 默认工具定义(在 pipeline-core 未加载时使用)──
@ -402,15 +411,17 @@ class AgentExecutor:
pass
# 2. Tool Registry
# 产线隔离2026-09-05必须每个 executor 独立实例,禁止全局单例。
# 全局单例会让先启动的产线会话注册的工具(如 sdlc 的 list_projects
# 永久残留,之后通用会话把全部工具塞给 LLM → 通用助手看到并调用
# 其他产线的工具。config.tools 已由 load_agent_config 按会话裁剪,
# 这里只注册本会话自己的工具。
try:
from pipeline_core.tool_registry import get_tool_registry
self._tool_registry = get_tool_registry()
# 把 config.tools 注册进 registryregistry 是全局单例,可能为空)
if self._tool_registry and self.config.tools:
existing = set(self._tool_registry.get_tool_names())
from pipeline_core.tool_registry import ToolRegistry
self._tool_registry = ToolRegistry()
if self.config.tools:
for t in self.config.tools:
if t.name not in existing:
self._tool_registry.register(t)
self._tool_registry.register(t)
except ImportError:
self._tool_registry = None
@ -433,7 +444,10 @@ class AgentExecutor:
# 5. pipeline_id 为空时 fallback 到默认产线(保证 slash/ability/skill 统一按产线挂载)
# 入口已指定 default_pipeline_id 时优先用它(投标/开发产线独立入口),否则用引擎默认。
if not self.pipeline_id:
# 产线隔离2026-09-05纯通用会话generic绝不回退默认产线——
# 回退会让 _execute_ability_tool 拿到 sdlc_general 能力包,通用助手
# 就能调用 list_projects 等产线工具。
if not self.pipeline_id and not self.generic:
try:
from pipeline_core import DEFAULT_ABILITY_ID
self.pipeline_id = DEFAULT_ABILITY_ID
@ -497,24 +511,32 @@ class AgentExecutor:
prompt += f"\n当前项目: {proj_name}\n" if proj_name else ""
# 注入记忆(分域:通用 + 产线 + 项目 叠加)
# 产线隔离2026-09-05纯通用会话只读 global 通用记忆,绝不带
# 「产线+项目」叠加——否则通用助手会看到产线专属记忆。
if self.config.memory.enabled and self._memory_store:
mem_block = await self._memory_store.build_prompt_block(
max_entries=15, scope="pipeline", scope_id=self.pipeline_id)
if self.project_id:
proj_block = await self._memory_store.build_prompt_block(
max_entries=5, scope="project", scope_id=self.project_id)
if proj_block:
mem_block = (mem_block + "\n" + proj_block) if mem_block else proj_block
if self.generic:
mem_block = await self._memory_store.build_prompt_block(
max_entries=15, scope="global")
else:
mem_block = await self._memory_store.build_prompt_block(
max_entries=15, scope="pipeline", scope_id=self.pipeline_id)
if self.project_id:
proj_block = await self._memory_store.build_prompt_block(
max_entries=5, scope="project", scope_id=self.project_id)
if proj_block:
mem_block = (mem_block + "\n" + proj_block) if mem_block else proj_block
if mem_block:
prompt += f"\n\n## 持久记忆\n{mem_block}"
# 注入技能六级隔离global→org→pipeline→role→project→user
if self.config.skills.enabled and self._skill_loader:
pipeline_id = self.pipeline_id if self.config.skills.enable_pipeline else ""
role = self.role if self.config.skills.enable_role else ""
project_id = self.project_id if self.config.skills.enable_project else ""
org_id = self.org_id if self.config.skills.enable_org else ""
user_id = self.user_id if self.config.skills.enable_user else ""
# 产线隔离2026-09-05纯通用会话只检索 global 技能,
# 不挂产线/角色/项目/机构技能。
pipeline_id = "" if self.generic else (self.pipeline_id if self.config.skills.enable_pipeline else "")
role = "" if self.generic else (self.role if self.config.skills.enable_role else "")
project_id = "" if self.generic else (self.project_id if self.config.skills.enable_project else "")
org_id = "" if self.generic else (self.org_id if self.config.skills.enable_org else "")
user_id = "" if self.generic else (self.user_id if self.config.skills.enable_user else "")
# 技能检索必须 LLM 做(语义匹配,非关键词硬编码)
catalog = self._skill_loader.get_skill_catalog(
pipeline_id=pipeline_id, role=role, project_id=project_id,
@ -745,6 +767,9 @@ class AgentExecutor:
async def _execute_ability_tool(self, tool_name: str, params: dict) -> Optional[str]:
"""产线能力包工具:按 pipeline_id 从 PipelineAbility 注册表取 handler 执行。"""
# 产线隔离2026-09-05纯通用会话不挂任何产线能力包执行层直接拒绝。
if self.generic:
return None
try:
from pipeline_core import get_ability, DEFAULT_ABILITY_ID
@ -778,6 +803,13 @@ class AgentExecutor:
p = params or {}
pid = self.project_id
# 产线隔离2026-09-05纯通用会话拒绝一切项目管理工具——此时
# tool_name 已经过 _PROJECT_TOOL_ALIASES 别名归一,别名变体同样被拦。
# schema 层load_agent_config 剔除)+ 执行层(这里)双保险。
if self.generic and tool_name in _PROJECT_TOOL_NAMES:
return ("FAIL: 当前是通用助手会话(未挂产线插件),不提供项目管理能力,"
"无法查看或操作任何产线的项目。")
# 通用内建工具映射core 内核,产线无关)。
# 产线专属工具create_task/diagnose_project 等)已迁移到 sdlc_ability 能力包,
# 由 _execute_ability_tool 按 pipeline_id 动态挂载。

View File

@ -1155,14 +1155,16 @@ async def _h_list_projects(sor, p, ctx):
if _u:
org_id = getattr(_u[0], "orgid", "") or ""
status = (p.get("status", "") or "").strip()
# 产线隔离强制真实产线会话space≠general只列本产线项目——
# 投标/商机产线不串出开发产线的项目。通用助手会话不过滤。
pipeline_filter = None
# 产线隔离强制2026-09-05 收紧真实产线会话space≠general只列本产线项目——
# 投标/商机产线不串出开发产线的项目。
# space=='general' 只会出现在纯通用会话(无产线插件),此时绝不列任何项目
# (通用助手不得看到/操作其他产线的项目);上游 _execute_ability_tool 对
# generic 会话已直接拒绝,这里是执行层兜底。
space = ctx.get("space", "") or ""
if space and space != "general":
pipeline_filter = space
if not space or space == "general":
return "FAIL: 当前会话无产线上下文,不提供项目列表。"
lst = await list_projects(org_id=org_id or None, status=status or None,
pipeline_id=pipeline_filter)
pipeline_id=space)
if not lst:
return "暂无项目"
lines = []