Files
xiaoli-cli/xcli_core/safety.py
T
shuiyu67andWorkBuddy a3977ecc85 feat: opencode 式 Plan 模式(只读调研 + /build 批准执行)
- 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>
2026-08-05 17:25:17 +08:00

339 lines
12 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.
"""
安全层 - 统一的指令安全检查
拦截所有工具调用,通过 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