feat(项目管理): 会话agent项目终止/删除能力+人类owner代码层门禁

- agent_loop_v2: GENERAL_TOOLS新增project_info/list_my_projects/pause/resume/delete_project 5工具+handler(_resolve_owned_project代码层校验owner, 不靠prompt)
- sdlc_ability: 8个项目生命周期handler(start/complete/archive/reopen/pause/resume/backup/delete)全加_require_project_owner门禁——能力层优先于通用层分发,不加则绕过校验;审计who记登录用户
- 别名归一化防LLM编造工具名;_needs_confirmation同步归一防别名绕过确认门
- pause/resume/delete标requires_confirmation(会话确认→代码校验双重门)
This commit is contained in:
ymq 2026-09-01 11:51:48 +08:00
parent 6f72e777da
commit 2dcd771533
2 changed files with 237 additions and 11 deletions

View File

@ -26,6 +26,24 @@ from typing import AsyncGenerator, Dict, List, Optional
logger = logging.getLogger("pipeline.agent_executor")
# 项目管理工具别名表LLM 常编造近义名(停止/终止/删除项目的各种说法),
# 归一到真实工具名。只加「语义等价」的别名,不扩大权限(权限校验在代码层)。
# 注意:不能映射既有真实工具名(如 list_projects 是 sdlc_ability 的产线项目列表
# 工具,语义与 list_my_projects 不同),否则会劫持既有功能。
_PROJECT_TOOL_ALIASES = {
"stop_project": "pause_project",
"terminate_project": "pause_project",
"suspend_project": "pause_project",
"halt_project": "pause_project",
"unpause_project": "resume_project",
"continue_project": "resume_project",
"remove_project": "delete_project",
"destroy_project": "delete_project",
"get_project_info": "project_info",
"show_project": "project_info",
"my_projects": "list_my_projects",
}
from .workspace import WORKSPACE_BASE, GENERAL_SPACE
@ -669,6 +687,9 @@ class AgentExecutor:
2. SDLC 内建工具兼容 cockpit_chat.dspy TOOLS
3. ask_user始终可用
"""
# 别名归一化LLM 会编造工具名2026-08-31 项目管理的常见变体),
# 映射到真实工具,避免「未知工具」导致用户指令落空。
tool_name = _PROJECT_TOOL_ALIASES.get(tool_name, tool_name)
# 1. 注册的 handler
if self._tool_registry:
handler = self._tool_registry.get_handler(tool_name)
@ -761,6 +782,12 @@ class AgentExecutor:
handlers = {
"switch_project": self._t_switch_project,
"create_project": self._t_create_project,
# ── 项目管理(终止/删除类代码层校验人类 owner──
"project_info": self._t_project_info,
"list_my_projects": self._t_list_my_projects,
"pause_project": self._t_pause_project,
"resume_project": self._t_resume_project,
"delete_project": self._t_delete_project,
"run_command": self._t_run_command,
# ── 通用工具集Hermes CLI 能力子集)──
"read_file": self._t_read_file,
@ -943,6 +970,154 @@ class AgentExecutor:
await self._persist_project(sor, pid_val)
return f"OK: 已创建项目 {name}"
# ── 项目管理工具2026-08-31 新增:会话 agent 获得项目管理能力)──
# 安全模型:终止(暂停)/删除等指令必须来自项目的人类 owner。
# 校验在代码层做_resolve_owned_project 内调 check_project_owner
# 不依赖 prompt——LLM 可以被注入/诱导,但校验函数只认数据库事实。
# owner 定义sd_projects.created_by == 当前登录用户self.user_id
async def _resolve_owned_project(self, sor, p):
"""解析目标项目并校验操作者是否为人类 owner。
project_name 缺省时用当前项目self.project_id给了名称则在当前产线
空间内精确匹配返回 (project_id, project_name, owner_err)
owner_err 非空 = 无权限或项目不存在调用方必须原样拒绝
"""
from .project_capability import check_project_owner
name = (p.get("project_name") or "").strip()
if not name:
pid = self.project_id
if not pid:
return "", "", "当前会话没有选中项目,请先说明要操作哪个项目(或先切换项目)"
recs = await sor.sqlExe(
"SELECT id, name, created_by FROM sd_projects WHERE id=${pid}$",
{"pid": pid})
await sor.sqlExe("COMMIT", {})
if not recs:
return "", "", "当前项目已不存在"
pname = getattr(recs[0], "name", "") or pid
else:
recs = await sor.sqlExe(
"SELECT id, name, created_by FROM sd_projects "
"WHERE name=${n}$ AND pipeline_id=${s}$",
{"n": name, "s": self.space})
await sor.sqlExe("COMMIT", {})
if not recs:
return "", "", f"项目不存在:{name}(当前产线空间 {self.space}"
pid = getattr(recs[0], "id", "")
pname = getattr(recs[0], "name", "") or pid
# 代码层 owner 校验:指令必须来自该项目的人类 owner创建者
ok, err = await check_project_owner(pid, self.user_id or "", sor)
if not ok:
return "", "", f"权限不足:{err}(项目「{pname}」的 owner 才可执行此操作)"
return pid, pname, ""
async def _t_project_info(self, sor, p, pid):
tpid, pname, err = await self._resolve_owned_project(sor, p)
if err:
return f"FAIL: {err}"
recs = await sor.sqlExe(
"SELECT id, name, description, status, pipeline_id, org_id, "
"created_by, created_at, updated_at FROM sd_projects WHERE id=${pid}$",
{"pid": tpid})
await sor.sqlExe("COMMIT", {})
if not recs:
return "FAIL: 项目不存在"
r = recs[0]
lines = [
f"项目:{getattr(r, 'name', '')}",
f"状态:{getattr(r, 'status', '')}",
f"描述:{getattr(r, 'description', '') or '(无)'}",
f"产线:{getattr(r, 'pipeline_id', '') or '(未绑定)'}",
f"创建:{getattr(r, 'created_at', '')} 更新:{getattr(r, 'updated_at', '')}",
]
# 迭代概况
iters = await sor.sqlExe(
"SELECT iteration_name, status FROM sd_iterations WHERE project_id=${pid}$ ORDER BY seq_no",
{"pid": tpid})
await sor.sqlExe("COMMIT", {})
if iters:
lines.append("迭代:" + "".join(
f"{getattr(i, 'iteration_name', '')}({getattr(i, 'status', '')})" for i in iters[:10]))
return "\n".join(lines)
async def _t_list_my_projects(self, sor, p, pid):
# 只列当前用户创建的项目(不暴露他人项目)
uid = self.user_id or ""
if not uid:
return "FAIL: 未登录,无法列出你的项目"
status = (p.get("status") or "").strip()
sql = ("SELECT id, name, status, pipeline_id, created_at FROM sd_projects "
"WHERE created_by=${uid}$")
params = {"uid": uid}
if status:
sql += " AND status=${st}$"
params["st"] = status
sql += " ORDER BY created_at DESC LIMIT 30"
recs = await sor.sqlExe(sql, params)
await sor.sqlExe("COMMIT", {})
if not recs:
return f"你名下没有项目{('(状态=' + status + '') if status else ''}"
lines = []
for r in recs:
lines.append(f"- {getattr(r, 'name', '')} {getattr(r, 'status', '')} "
f"产线 {getattr(r, 'pipeline_id', '') or ''} {getattr(r, 'created_at', '')}")
return f"你创建的项目({len(lines)} 个):\n" + "\n".join(lines)
async def _t_pause_project(self, sor, p, pid):
tpid, pname, err = await self._resolve_owned_project(sor, p)
if err:
return f"FAIL: {err}"
from .project_capability import pause_project
ok, msg = await pause_project(tpid, who=self.user_id, agent_id="cockpit")
if not ok:
return f"FAIL: {msg}"
logger.info("cockpit pause_project: %s by user=%s", tpid, self.user_id)
return f"OK: 项目「{pname}」已暂停(终止推进)。需要时可用 resume_project 恢复。"
async def _t_resume_project(self, sor, p, pid):
tpid, pname, err = await self._resolve_owned_project(sor, p)
if err:
return f"FAIL: {err}"
from .project_capability import resume_project
ok, msg = await resume_project(tpid, who=self.user_id, agent_id="cockpit")
if not ok:
return f"FAIL: {msg}"
logger.info("cockpit resume_project: %s by user=%s", tpid, self.user_id)
return f"OK: 项目「{pname}」已恢复推进。"
async def _t_delete_project(self, sor, p, pid):
confirm = p.get("confirm")
if str(confirm).lower() not in ("true", "1", "yes"):
return ("FAIL: 删除项目不可撤销。请先向用户复述项目名与后果,"
"取得明确确认后再带 confirm=true 调用本工具。")
tpid, pname, err = await self._resolve_owned_project(sor, p)
if err:
return f"FAIL: {err}"
# 删除后清掉本会话的项目上下文(否则留下悬空指针,下次打开报「请先切换项目」)
from .project_capability import delete_project
ok, msg = await delete_project(tpid, who=self.user_id, agent_id="cockpit", confirm=True)
if not ok:
return f"FAIL: {msg}"
if self.project_id == tpid:
self.project_id = ""
# 清会话级+全局「当前项目」指针_persist_project 对空 pid 直接 return
# 这里直接清,否则留下悬空指针,下次打开报「请先切换项目」)
try:
await sor.sqlExe(
"UPDATE pipeline_session_settings SET current_project_id='' "
"WHERE user_id=${uid}$ AND session_id=${sid}$",
{"uid": self.user_id or "", "sid": self.session_id or ""})
await sor.sqlExe(
"UPDATE pipeline_agent_settings SET current_project_id='' "
"WHERE user_id=${uid}$",
{"uid": self.user_id or ""})
await sor.sqlExe("COMMIT", {})
except Exception as e:
logger.warning(f"clear project pointer after delete failed: {e}")
logger.info("cockpit delete_project: %s by user=%s", tpid, self.user_id)
return f"OK: 项目「{pname}」已删除(含归档备份)。{msg}"
async def _t_run_command(self, sor, p, pid):
cmd = p.get("command", "")
if not cmd:
@ -1433,7 +1608,11 @@ class AgentExecutor:
会话级全部确认approve_all时直接放行否则 run_command 只对
危险命令确认普通命令自动执行其他 requires_confirmation 工具一律确认
别名先归一再查注册表2026-08-31 修复否则 LLM 用别名 stop_project
调需确认工具时注册表查不到 误判无需确认 绕过确认门直接执行
"""
tool_name = _PROJECT_TOOL_ALIASES.get(tool_name, tool_name)
if self._session and getattr(self._session, "approve_all", False):
return False
if self._tool_registry:

View File

@ -184,10 +184,10 @@ SDL_TOOLS = [
ToolDefinition(name="complete_project", description="完成项目(active→completed)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="archive_project", description="归档项目(completed→archived)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="reopen_project", description="重新打开归档项目(archived→active)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="pause_project", description="暂停项目推进(active→pausedPM 不再推进,仅用户明确指令暂停时用)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="resume_project", description="恢复项目推进(paused→active)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="pause_project", description="暂停项目推进(active→pausedPM 不再推进,仅用户明确指令暂停时用;仅项目人类 owner 可执行)", parameters={"project_id": "项目ID"}, category="project", requires_confirmation=True),
ToolDefinition(name="resume_project", description="恢复项目推进(paused→active;仅项目人类 owner 可执行)", parameters={"project_id": "项目ID"}, category="project", requires_confirmation=True),
ToolDefinition(name="backup_project", description="归档备份项目(打包工作目录+数据记录为tgz到机构工作目录_archive/)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="delete_project", description="删除项目(需二次确认confirm=true删除前自动归档备份tgz)", parameters={"project_id": "项目ID", "confirm": "二次确认必须为true"}, category="project"),
ToolDefinition(name="delete_project", description="删除项目(需二次确认confirm=true删除前自动归档备份tgz;仅项目人类 owner 可执行)", parameters={"project_id": "项目ID", "confirm": "二次确认必须为true"}, category="project", requires_confirmation=True),
ToolDefinition(name="cleanup_orphans", description="清扫指向已删项目/迭代/任务的孤儿残留记录(需confirm=truedry_run=true只统计不删)", parameters={"confirm": "二次确认必须为true", "dry_run": "true=只统计不删除(可选)"}, category="admin"),
# ── 迭代 ──
ToolDefinition(name="list_iterations", description="查看当前项目迭代列表", parameters={"status": "按状态筛选(可选)"}, category="iteration"),
@ -1021,6 +1021,29 @@ async def _resolve_id(sor, table, fid):
return ""
async def _require_project_owner(sor, ctx, pid):
"""项目生命周期变更的 owner 门禁2026-08-31 安全加固)。
会话 agent 的项目终止/删除等指令必须来自该项目的**人类 owner**
校验在代码层做不靠 promptctx.user_id 是网关层登录态无法被 LLM 伪造
仅当 created_by == 登录用户时放行
user_id无人值守/agent 直调一律拒绝生命周期变更只允许人类 owner
从会话发起产线内部如需暂停 failed-poller 熔断直接调
project_capability不走本工具层
返回 (ok, err)ok=True 放行ok=False err 为拒绝原因原样返回给调用方
"""
from .project_capability import check_project_owner
uid = ctx.get("user_id", "") or ""
if not uid:
return False, "权限不足:项目生命周期变更(暂停/恢复/归档/删除)必须由登录用户发起,且须为项目 owner。当前调用无登录身份已拒绝。"
ok, err = await check_project_owner(pid, uid, sor)
if not ok:
return False, f"权限不足:{err}。终止/删除等项目指令只接受该项目的创建者(人类 owner"
return True, ""
async def _resolve_task_id(sor, task_id, pid):
"""任务 ID 前缀兜底租户隔离精确匹配失败且入参≥6位时 LIKE 前缀解析完整 ID。"""
if not task_id:
@ -1112,8 +1135,11 @@ async def _h_start_project(sor, p, ctx):
if not pid:
return "需要项目ID"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
ok, err = await _require_project_owner(sor, ctx, pid)
if not ok:
return f"ERROR: {err}"
from .project_capability import start_project
ok, msg = await start_project(pid, who="agent.main_agent")
ok, msg = await start_project(pid, who=ctx.get("user_id") or "agent.main_agent")
return f"OK: {msg}" if ok else f"ERROR: {msg}"
@ -1167,8 +1193,11 @@ async def _h_complete_project(sor, p, ctx):
if not pid:
return "需要项目ID"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
ok, err = await _require_project_owner(sor, ctx, pid)
if not ok:
return f"ERROR: {err}"
from .project_capability import complete_project
ok, msg = await complete_project(pid, who="agent.main_agent")
ok, msg = await complete_project(pid, who=ctx.get("user_id") or "agent.main_agent")
return f"OK: {msg}" if ok else f"ERROR: {msg}"
@ -1177,8 +1206,11 @@ async def _h_archive_project(sor, p, ctx):
if not pid:
return "需要项目ID"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
ok, err = await _require_project_owner(sor, ctx, pid)
if not ok:
return f"ERROR: {err}"
from .project_capability import archive_project
ok, msg = await archive_project(pid, who="agent.main_agent")
ok, msg = await archive_project(pid, who=ctx.get("user_id") or "agent.main_agent")
return f"OK: {msg}" if ok else f"ERROR: {msg}"
@ -1187,8 +1219,11 @@ async def _h_reopen_project(sor, p, ctx):
if not pid:
return "需要项目ID"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
ok, err = await _require_project_owner(sor, ctx, pid)
if not ok:
return f"ERROR: {err}"
from .project_capability import reopen_project
ok, msg = await reopen_project(pid, who="agent.main_agent")
ok, msg = await reopen_project(pid, who=ctx.get("user_id") or "agent.main_agent")
return f"OK: {msg}" if ok else f"ERROR: {msg}"
@ -1197,8 +1232,11 @@ async def _h_pause_project(sor, p, ctx):
if not pid:
return "需要项目ID"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
ok, err = await _require_project_owner(sor, ctx, pid)
if not ok:
return f"ERROR: {err}"
from .project_capability import pause_project
ok, msg = await pause_project(pid, who="agent.main_agent")
ok, msg = await pause_project(pid, who=ctx.get("user_id") or "agent.main_agent")
return f"OK: {msg}" if ok else f"ERROR: {msg}"
@ -1207,8 +1245,11 @@ async def _h_resume_project(sor, p, ctx):
if not pid:
return "需要项目ID"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
ok, err = await _require_project_owner(sor, ctx, pid)
if not ok:
return f"ERROR: {err}"
from .project_capability import resume_project
ok, msg = await resume_project(pid, who="agent.main_agent")
ok, msg = await resume_project(pid, who=ctx.get("user_id") or "agent.main_agent")
return f"OK: {msg}" if ok else f"ERROR: {msg}"
@ -1217,8 +1258,11 @@ async def _h_backup_project(sor, p, ctx):
if not pid:
return "需要项目ID"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
ok, err = await _require_project_owner(sor, ctx, pid)
if not ok:
return f"ERROR: {err}"
from .project_capability import backup_project
ok, msg = await backup_project(pid, who="agent.main_agent")
ok, msg = await backup_project(pid, who=ctx.get("user_id") or "agent.main_agent")
return f"OK: {msg}" if ok else f"ERROR: {msg}"
@ -1230,8 +1274,11 @@ async def _h_delete_project(sor, p, ctx):
if confirm != "true":
return "ERROR: 删除项目需二次确认,请传 confirm=true 后再执行(删除前会自动归档备份到机构工作目录 _archive/"
pid = await _resolve_id(sor, "sd_projects", pid) or pid
ok, err = await _require_project_owner(sor, ctx, pid)
if not ok:
return f"ERROR: {err}"
from .project_capability import delete_project
ok, msg = await delete_project(pid, who="agent.main_agent", confirm=True)
ok, msg = await delete_project(pid, who=ctx.get("user_id") or "agent.main_agent", confirm=True)
return f"OK: {msg}" if ok else f"ERROR: {msg}"