Coverage for merco/sandbox/guard.py: 98%
104 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
1"""工具执行守卫 — 细粒度敏感命令规则链
3每条规则 = tool + pattern + action,依次匹配,首个命中生效::
5 guard = ToolGuard()
6 guard.rule("bash", "DROP TABLE", "deny") # 用户可加硬拦截
7 # 默认规则全部 ask,确认后放行
9配置 (merco.json)::
11 "sandbox_rules": [
12 {"tool": "bash", "pattern": "DROP TABLE", "action": "deny"},
13 ]
14"""
16import logging
17from abc import ABC, abstractmethod
18from dataclasses import dataclass
19from enum import Enum
21from .security import SecurityChecker
23logger = logging.getLogger("merco.guard")
26# ── 枚举和结果 ──────────────────────────────────────────────
28class GuardAction(Enum):
29 ALLOW = "allow" # 直接放行
30 DENY = "deny" # 直接拒绝
31 ASK = "ask" # 需要用户确认
33@dataclass
34class GuardResult:
35 action: GuardAction
36 command: str
37 rule: "GuardRule | None" = None
38 reason: str = ""
41class GuardConfirmationRequired(Exception):
42 """需要用户确认才能继续执行"""
44 def __init__(self, result: GuardResult):
45 self.result = result
46 super().__init__(f"需要确认: {result.command} - {result.reason}")
49# ── 规则 ──────────────────────────────────────────────────
51@dataclass
52class GuardRule:
53 tool: str # "bash" | "write_file" | "*" 所有工具
54 pattern: str
55 action: str # "ask" | "deny" | "allow"
57 def to_dict(self) -> dict:
58 return {"tool": self.tool, "pattern": self.pattern, "action": self.action}
60 @classmethod
61 def from_dict(cls, d: dict) -> "GuardRule":
62 return cls(
63 tool=d.get("tool", "bash"),
64 pattern=d["pattern"],
65 action=d.get("action", "ask"),
66 )
69# ── 默认规则(全部 ask,不硬拦截)─────────────────────────
71_DEFAULT_RULES: list[GuardRule] = [
72 GuardRule("bash", "rm -rf /", "ask"),
73 GuardRule("bash", "mkfs", "ask"),
74 GuardRule("bash", "dd if=", "ask"),
75 GuardRule("bash", "> /dev/sd", "ask"),
76 GuardRule("bash", "curl | bash", "ask"),
77 GuardRule("bash", "curl | sh", "ask"),
78 GuardRule("bash", "wget | bash", "ask"),
79 GuardRule("bash", "wget | sh", "ask"),
80 GuardRule("bash", "chmod 777 /", "ask"),
81 GuardRule("bash", "rm ", "ask"),
82 GuardRule("bash", "sudo ", "ask"),
83 GuardRule("bash", "chmod ", "ask"),
84 GuardRule("bash", "chown ", "ask"),
85 GuardRule("bash", "kill ", "ask"),
86 GuardRule("bash", "pkill ", "ask"),
87 GuardRule("bash", "git push", "ask"),
88 GuardRule("bash", "git reset --hard", "ask"),
89 GuardRule("bash", "docker rm", "ask"),
90 GuardRule("bash", "docker rmi", "ask"),
91 GuardRule("bash", "pip install", "ask"),
92 GuardRule("bash", "pip uninstall", "ask"),
93 GuardRule("bash", "npm install -g", "ask"),
94 GuardRule("bash", "npm uninstall -g", "ask"),
95 GuardRule("bash", "apt ", "ask"),
96 GuardRule("bash", "yum ", "ask"),
97 GuardRule("bash", "brew ", "ask"),
98 GuardRule("bash", "shutdown", "ask"),
99 GuardRule("bash", "reboot", "ask"),
100]
103# ── ToolGuard ─────────────────────────────────────────────
105class ToolGuard:
106 """工具执行守卫 — facade,委托给 PolicyPipeline
108 用法::
110 guard = ToolGuard()
111 guard.rule("bash", "DROP TABLE", "deny")
113 result = await guard.check("bash", {"command": "rm file.txt"})
114 if result.action == GuardAction.DENY:
115 return # 已拦截
117 插件用法::
119 pipeline = PolicyPipeline()
120 pipeline.use(BuiltinDefaultPolicy(mode="ask"))
121 pipeline.use(MyCustomPolicy())
122 guard = ToolGuard(pipeline=pipeline)
123 """
125 def __init__(self, pipeline: "PolicyPipeline" = None, mode: str = "ask", user_rules: list = None):
126 if pipeline:
127 self._pipeline = pipeline
128 else:
129 # 向后兼容:没传 pipeline 时自动创建默认 pipeline
130 self._pipeline = PolicyPipeline()
131 self._pipeline.use(BuiltinDefaultPolicy(mode=mode, user_rules=user_rules))
133 def rule(self, tool: str, pattern: str, action: str) -> "ToolGuard":
134 """添加规则(向后兼容)。在默认策略前插入一条自定义策略。"""
135 self._pipeline._policies.insert(0,
136 _SingleRulePolicy(tool, pattern, action))
137 return self
139 async def check(self, tool_name: str, arguments: dict) -> GuardResult:
140 return await self._pipeline.check(tool_name, arguments)
143# ── PermissionPolicy ───────────────────────────────────────
145class PermissionPolicy(ABC):
146 """安全策略基类"""
147 name: str = ""
149 @abstractmethod
150 async def check(self, tool_name: str, arguments: dict) -> GuardResult | None:
151 """检查工具。返回 GuardResult = 决断,None = 传给下一个策略"""
152 ...
155class PolicyPipeline:
156 """安全策略责任链"""
158 def __init__(self):
159 self._policies: list[PermissionPolicy] = []
161 def use(self, policy: PermissionPolicy) -> "PolicyPipeline":
162 """注册策略"""
163 self._policies.append(policy)
164 return self
166 async def check(self, tool_name: str, arguments: dict) -> GuardResult:
167 """依次检查,首个返回非 None 的结果生效"""
168 for policy in self._policies:
169 result = await policy.check(tool_name, arguments)
170 if result is not None:
171 return result
172 return GuardResult(action=GuardAction.ALLOW, command="")
175class _SingleRulePolicy(PermissionPolicy):
176 """单条规则的策略包装"""
177 name = "single_rule"
179 def __init__(self, tool, pattern, action):
180 self._tool = tool
181 self._pattern = pattern
182 self._action = action
184 async def check(self, tool_name, arguments):
185 command = arguments.get("command", "")
186 if self._tool not in (tool_name, "*"):
187 return None
188 if self._pattern not in command:
189 return None
190 return GuardResult(action=GuardAction(self._action), command=command)
193# ── BuiltinDefaultPolicy ───────────────────────────────────
195class BuiltinDefaultPolicy(PermissionPolicy):
196 """默认安全策略 — 包装 30 条默认规则 + SecurityChecker + mode logic"""
197 name = "builtin_default"
199 def __init__(self, mode: str = "ask", user_rules: list = None):
200 self.mode = mode
201 self._rules: list[GuardRule] = []
202 if user_rules:
203 for r in user_rules:
204 self._rules.append(
205 GuardRule.from_dict(r) if isinstance(r, dict) else r
206 )
207 self._rules.extend(_DEFAULT_RULES)
209 async def check(self, tool_name: str, arguments: dict) -> GuardResult | None:
210 if self.mode == "auto":
211 return GuardResult(action=GuardAction.ALLOW, command="")
213 command = arguments.get("command", "")
214 path = arguments.get("path", "")
216 if path and tool_name != "bash":
217 ok, reason = SecurityChecker.check_file_path(path)
218 if not ok:
219 return GuardResult(action=GuardAction.DENY, command=path, reason=reason)
221 if command:
222 ok, reason = SecurityChecker.check_command(command)
223 if not ok:
224 return GuardResult(action=GuardAction.DENY, command=command, reason=reason)
226 for rule in self._rules:
227 if not self._tool_match(rule.tool, tool_name):
228 continue
229 if rule.pattern not in command:
230 continue
231 action = GuardAction(rule.action)
232 return GuardResult(action=action, command=command, rule=rule)
234 return GuardResult(action=GuardAction.ALLOW, command=command)
236 @staticmethod
237 def _tool_match(pattern: str, tool_name: str) -> bool:
238 return pattern == "*" or pattern == tool_name