ymq 84bef76401 feat(agent): 统一文件读取+联网检索+只读库查询+generic strict沙箱(对齐Hermes信息摄入能力)
- 新增 file_read.py: v1/v2 共用统一读取(分页续读+docx/pdf解析+显式截断告知)
  根治立项书76088字符只读到30000(v2)/12000硬截无提示(v1)的真实翻车
- 新增 web_tools.py: web_search(Bing RSS优先+HTML兜底)/fetch_url(超长落盘webcache+read_file续读)
  SSRF三层防护(公网域名/DNS私址拒绝/重定向逐跳)+不可信数据标注;
  修复 aiohttp content.read(n) 对 chunked 响应提前返回半截的坑(改 resp.read())
- 新增 db_query.py: query_project_data 只读白名单查询(强制project_id参数化+where/order_by注入校验+审计)
- agent_loop.py(v1角色agent): read_file 12000硬截→共享file_read分页; AGENT_TOOLS 加三工具(补required防全参必填);
  _run_shell 加 strict 档(通用会话:不挂平台目录+可写根收窄到用户专属目录); PM/QC/RETRO prompt 工具清单同步
- agent_loop_v2.py(v2会话agent): _t_read_file 改走共享模块; 新增三工具 handler; generic run_command 强制strict+无bwrap拒绝
2026-09-08 23:13:06 +08:00

155 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""db_query.py - 只读项目数据查询工具甲类能力v1 角色 agent / v2 会话 agent 共用)
对齐 Hermes 的「用工具查实证据、不靠猜」诊断能力agent 排查/核对时可直接查
项目关联表的真实数据(任务/交付件/Bug/审计等),不再只能靠 list_tasks 这类
固定视图,也不用 run_command 绕道 mysql。
安全设计(不靠 LLM 自觉,全部代码层硬门禁):
1. 表白名单:只允许查询与项目直接/间接关联的业务表(映射与
project_capability._dump_project_records 同源),其余表一律拒绝
users/permission/rolepermission/llm 等凭据与权限表绝不可查)。
2. 只读:仅 SELECTSQL 前缀硬校验;禁多语句(分号截断)。
3. 强制项目过滤project_id 由代码注入(参数化),不接受调用方自定义
WHERE——跨项目/跨租户查询不可能构造出来。
4. 行数/字符上限 + 写审计record_auditappend-only
5. 通用会话generic无项目不可用本工具。
"""
import json
import re
_MAX_ROWS = 100 # 单次返回行数上限
_MAX_OUT_CHARS = 20000 # 输出字符上限
# 表 → 强制项目过滤 SQL与 project_capability._dump_project_records 同源)。
# project_id 参数化注入;间接关联表用子查询回项目。
TABLE_SCOPES = {
# 直接关联
'sd_projects': "SELECT * FROM sd_projects WHERE id=${pid}$",
'sd_iterations': "SELECT * FROM sd_iterations WHERE project_id=${pid}$",
'pipeline_tasks': "SELECT * FROM pipeline_tasks WHERE tenant_id=${pid}$",
'pipeline_deliverables': "SELECT * FROM pipeline_deliverables WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
'pipeline_project_agents': "SELECT * FROM pipeline_project_agents WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
'pipeline_agent_questions': "SELECT * FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
'sd_deploy_envs': "SELECT * FROM sd_deploy_envs WHERE project_id=${pid}$",
'sd_features': "SELECT * FROM sd_features WHERE project_id=${pid}$",
'sd_project_repos': "SELECT * FROM sd_project_repos WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
# 间接关联(子查询回项目)
'sd_bugs': "SELECT * FROM sd_bugs WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'sd_test_plans': "SELECT * FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'sd_test_cases': "SELECT * FROM sd_test_cases WHERE plan_id IN (SELECT id FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$))",
'audit_log': "SELECT * FROM audit_log WHERE tenant_id COLLATE utf8mb4_unicode_ci=${pid}$ ORDER BY created_at ASC",
}
# 产线扩展表(投标产线等,同样强制 project_id 过滤;表不存在时运行期容错)
PIPELINE_TABLE_SCOPES = {
'bid_chapters': "SELECT * FROM bid_chapters WHERE project_id=${pid}$",
'bid_doc_requirements': "SELECT * FROM bid_doc_requirements WHERE project_id=${pid}$",
'bid_qualifications': "SELECT * FROM bid_qualifications WHERE project_id=${pid}$",
'bid_qc_reviews': "SELECT * FROM bid_qc_reviews WHERE project_id=${pid}$",
'bid_documents': "SELECT * FROM bid_documents WHERE project_id=${pid}$",
'bid_kb_docs': "SELECT * FROM bid_kb_docs WHERE project_id=${pid}$",
'bid_members': "SELECT * FROM bid_members WHERE project_id=${pid}$",
'bid_reviews': "SELECT * FROM bid_reviews WHERE project_id=${pid}$",
'bid_analysis_history': "SELECT * FROM bid_analysis_history WHERE project_id=${pid}$",
'bid_cost_benefit': "SELECT * FROM bid_cost_benefit WHERE project_id=${pid}$",
}
ALL_SCOPES = dict(TABLE_SCOPES, **PIPELINE_TABLE_SCOPES)
def _rec_to_dict(rec):
if isinstance(rec, dict):
return dict(rec)
try:
return dict(rec)
except (TypeError, ValueError):
return {}
async def tool_query_project_data(sor, table: str, project_id: str,
where: str = '', order_by: str = '',
limit: int = _MAX_ROWS,
who: str = '', agent_id: str = '',
task_id: str = '') -> str:
"""查询项目关联表数据(只读,强制项目过滤)。
table: 白名单表名TABLE_SCOPES/PIPELINE_TABLE_SCOPES 键)
where: 可选附加过滤AND 拼接;仅允许「列 运算符 字面量」安全形态,代码校验)
order_by: 可选排序列(仅允许白名单表的列名形态)
"""
table = (table or '').strip().lower()
if not project_id:
return 'FAIL: 缺少项目上下文(本工具仅项目内可用)'
sql = ALL_SCOPES.get(table)
if not sql:
return ('FAIL: 表 %s 不在可查询白名单。可查询的表: %s'
% (table or '(空)', ', '.join(sorted(ALL_SCOPES.keys()))))
try:
limit = max(1, min(_MAX_ROWS, int(limit or _MAX_ROWS)))
except (ValueError, TypeError):
limit = _MAX_ROWS
# 附加 WHERE 安全校验:仅允许「标识符 运算符 字面量」的 AND 组合,
# 禁子查询/分号/注释/UNION——参数化之外的注入面在这里掐死。
extra = ''
if where and str(where).strip():
w = str(where).strip().rstrip(';')
# 字面量:单引号串/双引号串/数字/NULL/安全IN列表引号串或数字禁嵌套括号
_val = (r"('[^']*'|\"[^\"]*\"|[\d.]+|NULL|"
r"\((?:'[^']*'|[\d.]+)(?:\s*,\s*(?:'[^']*'|[\d.]+))*\))")
_col = r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?"
# 两种条件形态:比较运算符带值 / IS [NOT] NULL 不带值
_cmp = _col + r"\s*(=|!=|<>|>=|<=|>|<|LIKE|IN)\s*" + _val
_isnull = _col + r"\s+IS\s+(NOT\s+)?NULL"
_cond = r"(?:" + _cmp + r"|" + _isnull + r")"
safe_cond = r"^" + _cond + r"(\s+AND\s+" + _cond + r")*$"
if not re.match(safe_cond, w, re.I):
return ('FAIL: where 附加过滤只允许「列名 运算符 字面量」的 AND 组合'
"(禁子查询/UNION/分号/注释)。示例: state='running'")
extra = ' AND (' + w + ')'
order = ''
if order_by and str(order_by).strip():
o = str(order_by).strip()
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*(\s+(ASC|DESC))?$", o, re.I):
return 'FAIL: order_by 只允许单个列名(可带 ASC/DESC'
order = ' ORDER BY ' + o
full_sql = sql + extra + order + (' LIMIT %d' % limit)
try:
recs = await sor.sqlExe(full_sql, {"pid": project_id})
except Exception as e:
# 产线扩展表在其他产线库可能不存在——容错但报实情
return 'FAIL: 查询异常(表可能不存在于当前产线库): ' + str(e)[:200]
rows = [_rec_to_dict(r) for r in (recs or [])]
# 审计append-only谁在查什么
try:
from .audit import record_audit
await record_audit(project_id, 'db_query', table, 'query',
who=who or 'agent', agent_id=agent_id or '',
detail='rows=%d where=%s' % (len(rows), (where or '')[:100]),
sor=sor)
except Exception:
pass
if not rows:
return '(表 %s 在项目 %s 下无记录)' % (table, project_id[:8])
out = json.dumps(rows, ensure_ascii=False, default=str)
if len(out) > _MAX_OUT_CHARS:
# 超限:截断并告知(与 read_file 同一「显式截断」纪律)
kept = []
acc = 2
for r in rows:
s = json.dumps(r, ensure_ascii=False, default=str)
if acc + len(s) + 1 > _MAX_OUT_CHARS:
break
kept.append(s)
acc += len(s) + 1
out = '[' + ','.join(kept) + ']'
out += ('\n\n[⚠️ 截断提示:结果共 %d 行,以上仅展示前 %d 行(输出上限 %d 字符)。'
'缩小范围请加 where/limit 条件重查。]' % (len(rows), len(kept), _MAX_OUT_CHARS))
return '%s%dlimit=%d:\n%s' % (table, len(rows), limit, out)
return '%s%dlimit=%d:\n%s' % (table, len(rows), limit, out)