- safety.check 最高优先级加 Plan 只读拦截:写操作暂挂,只读放行 - BaseAICLI 新增 plan_mode / current_plan 状态 - _build_system_prompt 注入 PLAN 约束;/plan /build 命令路由与 handle 方法 - 速查表与启动提示补充 /plan /build - 新增 tests/test_plan_mode.py(16 项,纯函数+拦截+注入+状态转换) Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
339 lines
12 KiB
Python
339 lines
12 KiB
Python
"""
|
||
安全层 - 统一的指令安全检查
|
||
拦截所有工具调用,通过 AI 识别风险并请求用户确认
|
||
"""
|
||
import json
|
||
import threading
|
||
from typing import Optional, Tuple
|
||
|
||
|
||
# ── 安全模式 ──
|
||
MODE_UNRESTRICTED = 0 # 无限制:直接执行
|
||
MODE_NORMAL = 1 # 普通:AI 识别风险
|
||
MODE_MANUAL = 2 # 人工:所有指令都确认
|
||
|
||
MODE_NAMES = {
|
||
MODE_UNRESTRICTED: "无限制",
|
||
MODE_NORMAL: "普通",
|
||
MODE_MANUAL: "人工确认",
|
||
}
|
||
|
||
# ── Plan 模式只读白名单(黑名单法)──
|
||
# 值为 "*" 表示该工具整类视为写/有副作用,一律拦截;
|
||
# 值为列表时,仅当首个操作词命中列表中的写操作关键字才拦截;
|
||
# 值为 [] 表示该工具整类只读,一律放行(调研必需);
|
||
# 未登记的未知工具默认拦截(保守)。
|
||
PLAN_WRITE_RULES = {
|
||
# 整类拦截
|
||
"cmd_executor": "*",
|
||
"browser_auto": "*",
|
||
"network_tools": "*",
|
||
"scheduler": "*",
|
||
# 仅部分操作拦截
|
||
"code_editor": ["write", "create", "append", "edit", "insert", "delete_lines",
|
||
"multi", "replace", "rename", "move"],
|
||
"git_tools": ["commit", "push", "checkout", "reset", "merge", "revert", "clean",
|
||
"stash", "tag", "fetch", "pull", "branch -", "branch delete",
|
||
"branch -d", "branch -D", "remote add", "remote set", "amend"],
|
||
"file_manager": ["write", "delete", "move", "copy", "rename", "mkdir", "rm",
|
||
"create", "save", "touch"],
|
||
"sub_agent": ["run", "dispatch", "execute", "spawn", "create", "new", "task", "assign"],
|
||
"auto_engineer": ["run", "test", "build", "fix", "format", "apply", "generate",
|
||
"scaffold", "create", "refactor", "exec"],
|
||
"memory": ["write", "save", "add", "set", "delete", "edit", "update", "forget"],
|
||
# 整类放行(只读探索必需)
|
||
"tool_search": [],
|
||
"ai_search": [],
|
||
"code_search": [],
|
||
}
|
||
|
||
|
||
def plan_is_write_operation(tool_name: str, tool_args) -> bool:
|
||
"""Plan 模式下判断一次工具调用是否属于「写操作」(应被拦截)。
|
||
|
||
返回 True 表示会修改状态/文件/系统,应拦截;False 表示只读,可放行。
|
||
"""
|
||
rules = PLAN_WRITE_RULES.get(tool_name)
|
||
if rules is None:
|
||
# 未登记的未知工具:保守拦截
|
||
return True
|
||
if rules == "*":
|
||
return True
|
||
if not rules:
|
||
return False
|
||
args_l = str(tool_args).strip().lower()
|
||
parts = args_l.split()
|
||
op = parts[0] if parts else ""
|
||
for kw in rules:
|
||
kwp = kw.split()
|
||
if len(kwp) == 1:
|
||
# 单字关键字:精确匹配操作词,或作为其前缀/后缀(避免 "editor" 误中 "edit")
|
||
if op == kw or op.startswith(kw) or op.endswith(kw):
|
||
return True
|
||
else:
|
||
# 多词规则,如 "remote add" / "branch -d"
|
||
if args_l.startswith(kw) or " ".join(parts[:len(kwp)]) == kw:
|
||
return True
|
||
return False
|
||
|
||
|
||
class SafetyLayer:
|
||
"""统一安全检查层"""
|
||
|
||
def __init__(self):
|
||
self.mode = MODE_NORMAL
|
||
self.cli = None
|
||
self._analysis_cache = {} # 缓存分析结果
|
||
self._lock = threading.Lock()
|
||
|
||
def set_cli(self, cli):
|
||
self.cli = cli
|
||
|
||
def get_mode(self) -> int:
|
||
return self.mode
|
||
|
||
def get_mode_name(self) -> str:
|
||
return MODE_NAMES.get(self.mode, "未知")
|
||
|
||
def set_mode(self, mode: int):
|
||
self.mode = mode
|
||
|
||
def cycle_mode(self) -> str:
|
||
"""切换到下一个模式,返回新模式名称"""
|
||
self.mode = (self.mode + 1) % 3
|
||
return self.get_mode_name()
|
||
|
||
# ── 核心:拦截检查 ──
|
||
|
||
def check(self, tool_name: str, tool_args: str) -> Tuple[bool, str]:
|
||
"""
|
||
检查工具调用是否安全
|
||
返回: (允许执行, 消息)
|
||
"""
|
||
# ── Plan 模式只读约束(最高优先级,先于所有安全模式判断)──
|
||
# plan 模式下只允许只读调研工具,写操作一律暂挂,待 /build 批准后执行。
|
||
cli = self.cli
|
||
if cli is not None and getattr(cli, 'plan_mode', False):
|
||
if plan_is_write_operation(tool_name, tool_args):
|
||
return False, "PLAN 模式:写操作已暂挂(只读调研中)。请在 /build 批准后执行。"
|
||
return True, ""
|
||
|
||
# 无限制模式:直接放行
|
||
if self.mode == MODE_UNRESTRICTED:
|
||
return True, ""
|
||
|
||
# 人工确认模式:所有指令都确认
|
||
if self.mode == MODE_MANUAL:
|
||
return self._ask_user_confirm(
|
||
tool_name, tool_args,
|
||
risk_level="中",
|
||
impact="当前为人工确认模式,所有指令需要确认",
|
||
reason="模式要求"
|
||
)
|
||
|
||
# 普通模式:AI 识别风险
|
||
return self._ai_analyze(tool_name, tool_args)
|
||
|
||
# ── AI 风险识别 ──
|
||
|
||
def _ai_analyze(self, tool_name: str, tool_args: str) -> Tuple[bool, str]:
|
||
"""使用当前引擎独立分析指令风险(不带历史对话)"""
|
||
# 快速预检:明显安全的指令直接放行
|
||
if self._is_quick_safe(tool_name, tool_args):
|
||
return True, ""
|
||
|
||
# 获取引擎
|
||
engine = None
|
||
if self.cli and hasattr(self.cli, 'current_engine'):
|
||
engine = self.cli.current_engine
|
||
|
||
if not engine:
|
||
# 无引擎时降级为人工确认
|
||
return self._ask_user_confirm(
|
||
tool_name, tool_args,
|
||
risk_level="未知",
|
||
impact="无法进行 AI 风险分析",
|
||
reason="无可用引擎"
|
||
)
|
||
|
||
# 检查缓存
|
||
cache_key = f"{tool_name}:{tool_args[:100]}"
|
||
with self._lock:
|
||
if cache_key in self._analysis_cache:
|
||
cached = self._analysis_cache[cache_key]
|
||
if cached["safe"]:
|
||
return True, ""
|
||
return self._ask_user_confirm(
|
||
tool_name, tool_args,
|
||
risk_level=cached["level"],
|
||
impact=cached["impact"],
|
||
reason=cached["reason"]
|
||
)
|
||
|
||
# 调用 AI 独立分析(不带任何历史对话)
|
||
analysis_prompt = f"""你是一个安全分析器。分析以下工具调用是否存在安全风险。
|
||
|
||
工具: {tool_name}
|
||
参数: {tool_args}
|
||
|
||
只返回以下 JSON(不要其他内容):
|
||
{{"safe": true/false, "level": "低/中/高", "reason": "风险原因", "impact": "执行后可能的影响"}}
|
||
|
||
判断标准:
|
||
- safe=true: 读取操作、查看信息、无害查询
|
||
- safe=false: 删除文件、执行系统命令、修改配置、网络请求、git 危险操作
|
||
- level 低: 只读操作但可能泄露信息
|
||
- level 中: 修改操作可撤销
|
||
- level 高: 不可逆操作、系统级操作"""
|
||
|
||
try:
|
||
response = engine.generate_response(analysis_prompt, system_prompt="你是安全分析器。只返回 JSON,不要其他内容。")
|
||
|
||
if not response:
|
||
return self._ask_user_confirm(
|
||
tool_name, tool_args,
|
||
risk_level="未知",
|
||
impact="AI 分析无响应",
|
||
reason="引擎未返回结果"
|
||
)
|
||
|
||
# 解析 JSON
|
||
result = self._parse_analysis(response)
|
||
|
||
if result and result.get("safe") is True:
|
||
# 缓存安全结果
|
||
with self._lock:
|
||
self._analysis_cache[cache_key] = result
|
||
return True, ""
|
||
|
||
# 不安全,请求用户确认
|
||
level = result.get("level", "中") if result else "未知"
|
||
reason = result.get("reason", "AI 识别到潜在风险") if result else "AI 分析失败"
|
||
impact = result.get("impact", "未知影响") if result else "未知影响"
|
||
|
||
return self._ask_user_confirm(
|
||
tool_name, tool_args,
|
||
risk_level=level,
|
||
impact=impact,
|
||
reason=reason
|
||
)
|
||
|
||
except Exception as e:
|
||
return self._ask_user_confirm(
|
||
tool_name, tool_args,
|
||
risk_level="未知",
|
||
impact=f"AI 分析异常: {e}",
|
||
reason="分析过程出错"
|
||
)
|
||
|
||
def _parse_analysis(self, response: str) -> Optional[dict]:
|
||
"""解析 AI 返回的分析结果"""
|
||
import re
|
||
|
||
# 尝试直接解析 JSON
|
||
response = response.strip()
|
||
|
||
# 去掉 markdown 代码块
|
||
if response.startswith("```"):
|
||
response = response.split("\n", 1)[-1] if "\n" in response else response[3:]
|
||
if response.endswith("```"):
|
||
response = response[:-3]
|
||
response = response.strip()
|
||
|
||
try:
|
||
return json.loads(response)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
# 尝试从文本中提取 JSON
|
||
match = re.search(r'\{[^{}]*"safe"[^{}]*\}', response)
|
||
if match:
|
||
try:
|
||
return json.loads(match.group())
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
return None
|
||
|
||
def _is_quick_safe(self, tool_name: str, tool_args: str) -> bool:
|
||
"""快速预检:明显安全的操作直接放行"""
|
||
args = tool_args.strip().lower()
|
||
|
||
# 纯读取操作
|
||
safe_patterns = {
|
||
"code_editor": ["read_range", "find ", "regex ", "symbols", "imports",
|
||
"ast_info", "stats", "structure", "context", "todo",
|
||
"callers", "diff ", "diff_text"],
|
||
"git_tools": ["status", "log", "diff", "branch", "remote", "show",
|
||
"blame", "contributors", "summary"],
|
||
"file_manager": ["list", "read", "info", "search"],
|
||
"cmd_executor": [], # 命令执行不跳过
|
||
"auto_engineer": ["metrics", "security", "complexity", "duplicates",
|
||
"suggest", "info"],
|
||
"task_manager": ["list"],
|
||
"browser_auto": ["status", "title", "url", "text", "html", "attr",
|
||
"value", "eval", "query", "cookies"],
|
||
"sub_agent": ["list", "status", "result", "stats"],
|
||
"network_tools": ["ping", "status", "headers", "ip"],
|
||
"tool_search": [],
|
||
}
|
||
|
||
patterns = safe_patterns.get(tool_name, [])
|
||
for p in patterns:
|
||
if args.startswith(p):
|
||
return True
|
||
|
||
return False
|
||
|
||
# ── 用户交互 ──
|
||
|
||
def _ask_user_confirm(self, tool_name: str, tool_args: str,
|
||
risk_level: str = "中", impact: str = "",
|
||
reason: str = "") -> Tuple[bool, str]:
|
||
"""向用户展示风险信息并请求确认"""
|
||
from colorama import Fore, Style
|
||
|
||
# 风险等级颜色
|
||
level_colors = {
|
||
"低": Fore.YELLOW,
|
||
"中": Fore.LIGHTYELLOW_EX,
|
||
"高": Fore.RED,
|
||
"未知": Fore.LIGHTBLACK_EX,
|
||
}
|
||
level_color = level_colors.get(risk_level, Fore.YELLOW)
|
||
|
||
# 截断过长的参数
|
||
display_args = tool_args
|
||
if len(display_args) > 200:
|
||
display_args = display_args[:200] + "..."
|
||
|
||
print()
|
||
print(f" {Fore.RED}{'═' * 50}{Style.RESET_ALL}")
|
||
print(f" {Fore.RED} 安全检查{Style.RESET_ALL}")
|
||
print(f" {Fore.RED}{'═' * 50}{Style.RESET_ALL}")
|
||
print(f" {Fore.CYAN}工具:{Style.RESET_ALL} {tool_name}")
|
||
print(f" {Fore.CYAN}指令:{Style.RESET_ALL} {display_args}")
|
||
print(f" {level_color}风险:{Style.RESET_ALL} {risk_level}")
|
||
if reason:
|
||
print(f" {Fore.YELLOW}原因:{Style.RESET_ALL} {reason}")
|
||
if impact:
|
||
print(f" {Fore.YELLOW}影响:{Style.RESET_ALL} {impact}")
|
||
print(f" {Fore.RED}{'─' * 50}{Style.RESET_ALL}")
|
||
|
||
try:
|
||
choice = input(f" {Fore.WHITE}是否执行? (y/n): {Style.RESET_ALL}").strip().lower()
|
||
if choice in ('y', 'yes', '是'):
|
||
return True, "用户确认执行"
|
||
else:
|
||
return False, "用户取消执行"
|
||
except (KeyboardInterrupt, EOFError):
|
||
print()
|
||
return False, "用户中断"
|
||
|
||
|
||
# ── 全局单例 ──
|
||
_safety = SafetyLayer()
|
||
|
||
def get_safety() -> SafetyLayer:
|
||
return _safety
|