feat: AI 诊断 Agent 支持执行系统诊断 shell 命令

扩展 Agent 命令工具,除 kubectl 外新增节点宿主机系统诊断命令支持:

- agent_tools.py: 新增 shell 命令白名单分类器 (classify_shell_command)
  - 只读白名单: systemctl status/is-active、journalctl、crictl ps/logs、
    docker ps/logs、df、free、ss、ip addr、ps、mount、lsmod、dmesg、
    ping、nslookup、cat (限 /proc /sys /etc/kubernetes 等安全路径) 等 30+ 命令
  - 写白名单: systemctl restart/stop/start/reload 等需人工审批
  - 统一分发器 classify_agent_command 按命令前缀路由
  - 统一执行入口 execute_command 走 subprocess_exec (无 shell 注入风险)
- ai_agent.py: 更新 AGENT_SYSTEM_PROMPT 告知 AI 两类可用工具及安全规则
  - 三个调用点 (初始探测/分类/执行) 切换到统一接口
- main.py: 审批执行处改用 execute_command
- tests: 新增 19 个用例覆盖 shell 命令分类、混合执行、白名单拒绝
  - 全部 44 个测试通过
- 前端/README: 文案与文档补充 shell 命令支持说明

安全策略不变: 禁止 shell 操作符/管道/重定向/多命令拼接,
非白名单命令 (rm/vi/apt 等) 直接拒绝。
This commit is contained in:
cnbugs
2026-07-25 13:38:48 +08:00
parent f2254646c1
commit 32bea0d4ea
8 changed files with 430 additions and 39 deletions
+160 -7
View File
@@ -1,10 +1,19 @@
"""AI Agent 可用的 kubectl 命令工具和安全策略。"""
"""AI Agent 可用的命令工具和安全策略。
支持两类命令:
1. kubectl 命令 - 通过 classify_kubectl_command 分类
2. 系统诊断 shell 命令 - 通过 classify_shell_command 分类 (白名单)
classify_agent_command 统一分发,execute_command 自动选择执行方式。
所有命令均通过 subprocess_exec 执行 (无 shell),禁止管道、重定向和命令组合。
"""
import asyncio
import shlex
from dataclasses import dataclass
from backend.diagnosis import KUBECTL
# ── kubectl 命令分类 ──────────────────────────────────────
READ_ONLY_VERBS = {
"api-resources", "api-versions", "auth", "cluster-info", "config", "describe",
@@ -27,22 +36,27 @@ FORBIDDEN_TOKENS = {";", "|", "||", "&&", ">", ">>", "<", "<<", "`", "$"}
@dataclass(frozen=True)
class CommandDecision:
mode: str
mode: str # "read" (只读,自动执行) 或 "write" (修改,需审批)
requires_approval: bool
verb: str
verb: str # kubectl 子命令 或 shell 命令名
args: list[str]
def parse_kubectl_command(command: str) -> list[str]:
"""解析单条 kubectl 命令,不允许 shell、重定向命令组合。"""
def _parse_command(command: str) -> list[str]:
"""解析单条命令,禁止 shell 操作符、重定向命令组合。"""
if not isinstance(command, str) or not command.strip():
raise ValueError("命令不能为空")
if any(token in command for token in FORBIDDEN_TOKENS) or "\n" in command or "\r" in command:
raise ValueError("不允许 shell 操作符、重定向或多条命令")
try:
parts = shlex.split(command)
return shlex.split(command)
except ValueError as exc:
raise ValueError(f"命令格式错误: {exc}") from exc
def parse_kubectl_command(command: str) -> list[str]:
"""仅解析单条 kubectl 命令,不允许 shell、重定向或命令组合。"""
parts = _parse_command(command)
if not parts or parts[0] != "kubectl":
raise ValueError("只允许执行 kubectl 命令")
if len(parts) < 2:
@@ -91,8 +105,147 @@ def classify_kubectl_command(command: str) -> CommandDecision:
return CommandDecision("write", True, verb, args)
# ── 系统诊断 shell 命令分类 (白名单) ─────────────────────
# 只读诊断命令白名单:value 为 None 表示该命令本身只读,任意参数均可;
# 为集合时,第一个非 flag 参数必须属于该集合。
SHELL_READ_ONLY_COMMANDS: dict[str, set[str] | None] = {
"systemctl": {
"status", "is-active", "is-enabled", "is-failed", "list-units",
"list-sockets", "list-jobs", "show", "cat", "list-unit-files",
"list-dependencies",
},
"journalctl": None, # 只读,任意参数 (-u, -n, --no-pager, --since 等)
"crictl": {"ps", "inspect", "logs", "info", "version", "images", "stats"},
"docker": {"ps", "logs", "inspect", "stats", "version", "info", "images", "top"},
"df": None, "free": None, "uptime": None, "uname": None, "hostname": None,
"ip": {"addr", "address", "route", "link", "neighbor", "neigh", "rule"},
"ss": None, "netstat": None, "lsblk": None, "ls": None, "cat": None,
"head": None, "tail": None, "wc": None, "grep": None, "egrep": None, "fgrep": None,
"ps": None, "mount": None, "lsmod": None, "lscpu": None, "lsof": None,
"dmesg": None, "ping": None, "nslookup": None, "dig": None, "getent": None,
"timedatectl": None, "date": None, "stat": None, "file": None, "blkid": None,
}
# 系统修改命令白名单:需要人工审批
SHELL_WRITE_COMMANDS: dict[str, set[str] | None] = {
"systemctl": {"restart", "start", "stop", "reload", "enable", "disable", "daemon-reload"},
}
# ip 命令的写操作动词 (出现即拒绝自动执行)
_IP_WRITE_ACTIONS = {"set", "add", "del", "delete", "change", "replace", "append", "prepend"}
# cat 允许读取的安全路径
_SAFE_CAT_FILES = {"/etc/os-release", "/etc/hosts", "/etc/resolv.conf"}
_SAFE_CAT_DIRS = ("/proc/", "/sys/", "/etc/kubernetes/", "/etc/cni/", "/etc/systemd/system/")
def _is_safe_cat_path(path: str) -> bool:
if not path:
return False
if path in _SAFE_CAT_FILES:
return True
return any(path.startswith(d) for d in _SAFE_CAT_DIRS)
def _first_non_flag(args: list[str]) -> str | None:
for arg in args:
if not arg.startswith("-"):
return arg.lower()
return None
def parse_shell_command(command: str) -> list[str]:
"""解析单条 shell 诊断命令,禁止 shell 操作符和命令组合。"""
parts = _parse_command(command)
if not parts:
raise ValueError("命令不能为空")
return parts
def classify_shell_command(command: str) -> CommandDecision:
"""对白名单内的系统诊断命令进行分类。
systemctl 同时出现在只读和写白名单中:restart/stop 等走写表(需审批),
status/is-active 等走只读表(自动执行)。因此先检查写表,再检查只读表。
"""
parts = parse_shell_command(command)
binary = parts[0]
verb = _first_non_flag(parts[1:])
if binary in SHELL_WRITE_COMMANDS:
allowed = SHELL_WRITE_COMMANDS[binary]
if allowed is None or (verb is not None and verb in allowed):
return CommandDecision("write", True, binary, parts[1:])
if binary in SHELL_READ_ONLY_COMMANDS:
allowed = SHELL_READ_ONLY_COMMANDS[binary]
if allowed is not None and (verb is None or verb not in allowed):
raise ValueError(f"命令 {binary} 不支持该子命令: {verb}")
if binary == "ip" and any(arg in _IP_WRITE_ACTIONS for arg in parts[1:]):
raise ValueError("ip 命令包含写操作,请使用只读形式如 'ip addr show'")
if binary == "cat":
for arg in parts[1:]:
if not arg.startswith("-") and not _is_safe_cat_path(arg):
raise ValueError(
"cat 仅允许读取 /proc, /sys, /etc/kubernetes, /etc/cni, "
"/etc/systemd/system, /etc/os-release, /etc/hosts, /etc/resolv.conf"
)
return CommandDecision("read", False, binary, parts[1:])
raise ValueError(f"不允许执行该命令: {binary}")
def classify_agent_command(command: str) -> CommandDecision:
"""统一分类 Agent 命令:kubectl 或白名单系统诊断命令。"""
stripped = (command or "").strip()
if stripped.startswith("kubectl ") or stripped == "kubectl":
return classify_kubectl_command(stripped)
return classify_shell_command(stripped)
# ── 命令执行 ──────────────────────────────────────────────
async def execute_command(command: str, timeout: int = 30) -> dict:
"""执行单条 Agent 命令 (kubectl 或白名单系统诊断命令),无 shell。"""
decision = classify_agent_command(command)
parts = _parse_command(command)
if parts[0] == "kubectl":
executable = KUBECTL
args = decision.args
else:
executable = parts[0]
args = parts[1:]
proc = await asyncio.create_subprocess_exec(
executable,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return {
"command": command,
"mode": decision.mode,
"returncode": proc.returncode,
"stdout": stdout.decode(errors="replace")[:20000].strip(),
"stderr": stderr.decode(errors="replace")[:10000].strip(),
}
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
return {
"command": command,
"mode": decision.mode,
"returncode": -1,
"stdout": "",
"stderr": f"命令执行超时 ({timeout}s)",
}
async def execute_kubectl(command: str, timeout: int = 30) -> dict:
"""无 shell 执行 kubectl,并限制输出大小"""
"""执行单条 kubectl 命令 (兼容旧接口)"""
decision = classify_kubectl_command(command)
proc = await asyncio.create_subprocess_exec(
KUBECTL,