- safety.check 在 plan 模式之后、安全模式之前插入声明式权限判定: deny(阻断) > ask(强制确认) > allow(自动放行) > 默认(按安全模式) - 规则语法支持 "*"全匹配 / "tool"精确 / "tool:*"任意操作 / "tool:op"操作前缀 - 读取 config.permissions;提供 set_permissions 显式注入(测试/启动用) - config.json 顶层加 permissions 段(allow/ask/deny 示例,该文件被 gitignore 不入库) - 修复:test_normal_mode_does_not_plan_block 隔离真实 config 权限,避免污染 - 新增 tests/test_permissions.py(14 项:规则匹配+deny/ask/allow+优先级) Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
416 lines
16 KiB
Python
416 lines
16 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()
|
||
self._permissions_override = None # set_permissions 注入的显式权限
|
||
|
||
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()
|
||
|
||
# ── 声明式权限(opencode 式 allow/ask/deny)──
|
||
# 规则语法:
|
||
# "*" → 匹配任意工具/操作
|
||
# "tool" → 精确匹配工具名
|
||
# "tool:*" → 该工具任意操作
|
||
# "tool:op" → 工具名 + 参数首个词命中 op 前缀
|
||
# 优先级:deny(阻断)> ask(强制确认)> allow(自动放行)> 默认(按模式)
|
||
|
||
def set_permissions(self, allow=None, ask=None, deny=None):
|
||
"""显式注入权限规则(测试或启动时调用),优先级最高。"""
|
||
self._permissions_override = {
|
||
"allow": list(allow or []),
|
||
"ask": list(ask or []),
|
||
"deny": list(deny or []),
|
||
}
|
||
|
||
def _get_permissions(self) -> dict:
|
||
if self._permissions_override is not None:
|
||
return self._permissions_override
|
||
return self._load_permissions()
|
||
|
||
def _load_permissions(self) -> dict:
|
||
try:
|
||
from .config import load_config
|
||
except ImportError:
|
||
try:
|
||
from config import load_config
|
||
except ImportError:
|
||
return {"allow": [], "ask": [], "deny": []}
|
||
cfg = load_config() or {}
|
||
perm = cfg.get("permissions") or {}
|
||
return {
|
||
"allow": perm.get("allow", []) or [],
|
||
"ask": perm.get("ask", []) or [],
|
||
"deny": perm.get("deny", []) or [],
|
||
}
|
||
|
||
def _permission_match(self, rule: str, tool_name: str, tool_args) -> bool:
|
||
rule = (rule or "").strip()
|
||
if not rule:
|
||
return False
|
||
if rule == "*":
|
||
return True
|
||
if ":" in rule:
|
||
rtool, raction = rule.split(":", 1)
|
||
rtool = rtool.strip()
|
||
raction = raction.strip()
|
||
if rtool != tool_name:
|
||
return False
|
||
if raction in ("*", ""):
|
||
return True
|
||
op = (str(tool_args).strip().split()[0].lower()
|
||
if str(tool_args).strip() else "")
|
||
return op == raction or op.startswith(raction)
|
||
return rule == tool_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, ""
|
||
|
||
# ── 声明式权限(config.permissions: deny > ask > allow)──
|
||
perm = self._get_permissions()
|
||
# deny 最高优先级:直接阻断(即便无限制模式也生效)
|
||
for rule in perm.get("deny", []):
|
||
if self._permission_match(rule, tool_name, tool_args):
|
||
return False, f"权限拒绝(deny 规则 '{rule}' 命中): {tool_name}"
|
||
# ask:强制人工确认(覆盖自动放行,即便无限制模式也确认)
|
||
for rule in perm.get("ask", []):
|
||
if self._permission_match(rule, tool_name, tool_args):
|
||
return self._ask_user_confirm(
|
||
tool_name, tool_args,
|
||
risk_level="中",
|
||
impact="命中 ask 规则,需你确认后执行",
|
||
reason=f"权限规则 '{rule}'"
|
||
)
|
||
# allow:自动放行(跳过 AI 风险分析)
|
||
for rule in perm.get("allow", []):
|
||
if self._permission_match(rule, tool_name, tool_args):
|
||
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
|