"""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", "diff", "explain", "get", "logs", "top", "version", "wait", } WRITE_OR_INTERACTIVE_VERBS = { "annotate", "apply", "attach", "autoscale", "cordon", "cp", "create", "debug", "delete", "drain", "edit", "exec", "expose", "label", "patch", "port-forward", "replace", "rollout", "run", "scale", "set", "taint", "uncordon", } GLOBAL_FLAGS_WITH_VALUE = { "--as", "--as-group", "--as-uid", "--cache-dir", "--certificate-authority", "--client-certificate", "--client-key", "--cluster", "--context", "--kubeconfig", "--namespace", "-n", "--profile", "--request-timeout", "--server", "-s", "--tls-server-name", "--token", "--user", } FORBIDDEN_TOKENS = set() # 全部 shell 操作符均允许;安全靠白名单+危险命令检查 # 永远拒绝的武器级危险命令(无论是否在白名单中) _ALWAYS_DENY_CMDS = {"sh", "bash", "dash", "zsh", "dd", "format", "mkfs", "fdisk", "parted"} @dataclass(frozen=True) class CommandDecision: mode: str # "read" (只读,自动执行) 或 "write" (修改,需审批) requires_approval: bool verb: str # kubectl 子命令 或 shell 命令名 args: list[str] needs_shell: bool = False # True 时需用 shell=True 执行 def _has_shell_operators(command: str) -> bool: """检测命令是否包含 shell 操作符(管道、重定向、分号、逻辑连接符、多行等)。""" stripped = command.strip() if not stripped: return False if "\n" in stripped or "\r" in stripped: return True # 多行命令需要 shell=True in_single = in_double = False for i, ch in enumerate(stripped): if ch == "'" and not in_double: in_single = not in_single elif ch == '"' and not in_single: in_double = not in_double if in_single or in_double: continue if ch in ('|', '>', ';', '&', '<'): # 单纯 & 不算(后台任务标记) if ch == '&': if i + 1 < len(stripped) and stripped[i + 1] in ('&',): return True # && continue return True return False def _split_into_commands(command: str) -> list[str]: """按 shell 连接符(; && || | \n 等)分割命令块。 返回每个可执行段,忽略引号内的操作符。重定向符号 > < 不分割。 """ segments = [] buf = [] in_single = in_double = False i = 0 while i < len(command): ch = command[i] if ch == "'" and not in_double: in_single = not in_single elif ch == '"' and not in_single: in_double = not in_double if not in_single and not in_double: if ch in (';', '\n'): if buf: segments.append(''.join(buf).strip()) buf = [] i += 1 continue if ch == '|': if i + 1 < len(command) and command[i + 1] == '|': # || — 分割命令 if buf: segments.append(''.join(buf).strip()) buf = [] i += 2 continue # 单 | — 分割命令 if buf: segments.append(''.join(buf).strip()) buf = [] i += 1 continue if ch == '&': if i + 1 < len(command) and command[i + 1] == '&': # && — 分割命令 if buf: segments.append(''.join(buf).strip()) buf = [] i += 2 continue # 单独 & — 保留 buf.append(ch) i += 1 if buf: segments.append(''.join(buf).strip()) return [s for s in segments if s] def _command_has_dangerous_cmds(command: str) -> bool: """检查命令中任何可执行段是否包含武器级危险命令。""" for segment in _split_into_commands(command): try: seg_args = shlex.split(segment) except ValueError: continue if not seg_args: continue # 跳过 VAR=value 赋值 idx = 0 while idx < len(seg_args) and "=" in seg_args[idx] and not seg_args[idx].startswith("-"): idx += 1 if idx < len(seg_args) and seg_args[idx] in _ALWAYS_DENY_CMDS: return True # 也检查 export VAR=value 后的命令 if idx < len(seg_args) and seg_args[idx] == "export": idx2 = idx + 1 while idx2 < len(seg_args) and "=" in seg_args[idx2]: idx2 += 1 if idx2 < len(seg_args) and seg_args[idx2] in _ALWAYS_DENY_CMDS: return True return False def _parse_command(command: str) -> list[str]: """解析命令,检查合法性。 允许所有 shell 操作符(| > ; && || < 等)以及多行命令,安全靠白名单检查。 武器级危险命令 sh/bash/dd/mkfs 等在 classify 阶段处理。 """ if not isinstance(command, str) or not command.strip(): raise ValueError("命令不能为空") if _command_has_dangerous_cmds(command): raise ValueError("命令包含不允许的危险命令(sh/bash/dd/mkfs 等),请手动执行") try: 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: raise ValueError("kubectl 命令缺少操作类型") return parts[1:] def _find_verb(args: list[str]) -> str: index = 0 while index < len(args): arg = args[index] if arg == "--": break if arg in GLOBAL_FLAGS_WITH_VALUE: index += 2 continue if any(arg.startswith(flag + "=") for flag in GLOBAL_FLAGS_WITH_VALUE if flag.startswith("--")): index += 1 continue if arg.startswith("-"): index += 1 continue return arg.lower() raise ValueError("无法识别 kubectl 操作类型") READ_ONLY_CONFIG_SUBCOMMANDS = {"current-context", "get-clusters", "get-contexts", "get-users", "view"} def classify_kubectl_command(command: str) -> CommandDecision: args = parse_kubectl_command(command) verb = _find_verb(args) if verb == "config": try: verb_index = args.index(next(arg for arg in args if arg.lower() == "config")) subcommand = args[verb_index + 1].lower() except (StopIteration, ValueError, IndexError): return CommandDecision("write", True, verb, args) if subcommand in READ_ONLY_CONFIG_SUBCOMMANDS: return CommandDecision("read", False, verb, args) return CommandDecision("write", True, verb, args) if verb in READ_ONLY_VERBS: return CommandDecision("read", False, verb, args) if verb in WRITE_OR_INTERACTIVE_VERBS: return CommandDecision("write", True, verb, args) 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"}, "ctr": None, "nerdctl": {"ps", "logs", "inspect", "info", "version", "images", "stats", "top", "events", "system", "namespace", "network", "volume"}, "docker": {"ps", "logs", "inspect", "stats", "version", "info", "images", "top"}, "df": None, "du": None, "free": None, "uptime": None, "uname": None, "hostname": None, "ip": {"addr", "address", "route", "link", "neighbor", "neigh", "rule", "maddr", "monitor", "tunnel", "tuntap"}, "ss": None, "netstat": None, "lsblk": None, "ls": None, "cat": None, "head": None, "tail": None, "wc": None, "grep": None, "egrep": None, "fgrep": None, "ps": None, "pstree": 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, "top": None, "htop": None, "iostat": None, "vmstat": None, "mpstat": None, "pidstat": None, "sar": None, "nproc": None, "env": None, "which": None, "readlink": None, "realpath": None, "find": None, "sort": None, "uniq": None, "awk": None, "sed": None, "cut": None, "tr": None, "tee": None, "basename": None, "dirname": None, "xargs": None, "bc": None, "expr": None, "curl": None, "wget": None, "traceroute": None, "tracepath": None, "mtr": None, "nc": None, "nmap": None, "telnet": None, "tcpdump": None, "ethtool": None, "lspci": None, "lsusb": None, "dmidecode": None, "sysctl": None, "udevadm": None, "echo": None, "printf": None, "cd": None, "pwd": None, "export": None, "test": None, "[": None, "sleep": None, "source": None, ".": None, "timeout": None, "nohup": None, "unset": None, "shift": None, "return": None, "local": None, "readonly": None, "trap": None, "type": None, "command": None, "set": None, "shopt": None, "eval": None, "exec": None, "alias": None, "unalias": None, "declare": None, "typeset": None, "read": None, "mapfile": None, } # etcdctl 由 classify_shell_command 中的专用块处理 (两段式命令) # 从通用白名单中移除,避免被单段式逻辑误判 # etcdctl 单段式只读 verb _ETCDCTL_READ_VERBS = {"get", "watch", "version", "lock", "lease", "move-leader", "leader"} # etcdctl 单段式写 verb (需人工审批) _ETCDCTL_WRITE_VERBS = {"put", "del", "delete", "compact", "txn", "snapshot", "defrag"} # etcdctl 两段式 (verb, subverb) 只读组合 _ETCDCTL_READ_SUBVERBS = { "endpoint": {"status", "health", "hashkv"}, "member": {"list", "list-urls"}, "alarm": {"list"}, "auth": {"status"}, "user": {"list", "get"}, "role": {"list", "get"}, "check": {"perf", "datascale"}, } # etcdctl 两段式 (verb, subverb) 写组合 (需人工审批) _ETCDCTL_WRITE_SUBVERBS = { "member": {"add", "remove", "update"}, "alarm": {"disarm"}, "auth": {"enable", "disable"}, "user": {"add", "delete", "grant-role", "revoke-role", "passwd"}, "role": {"add", "delete", "grant-permission", "revoke-permission"}, } # 系统修改命令白名单:需要人工审批 SHELL_WRITE_COMMANDS: dict[str, set[str] | None] = { "systemctl": {"restart", "start", "stop", "reload", "enable", "disable", "daemon-reload"}, # 文件操作命令:任意参数都需审批 "rm": None, "cp": None, "mv": None, "chmod": None, "chown": None, "mkdir": None, "touch": None, "ln": None, } # ip 命令的写操作动词 (出现即拒绝自动执行) _IP_WRITE_ACTIONS = {"set", "add", "del", "delete", "change", "replace", "append", "prepend"} 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 等走只读表(自动执行)。因此先检查写表,再检查只读表。 含管道 (|) 或重定向 (>) 的命令:只检查管道前的 binary 是否在白名单内, 并设置 needs_shell=True 以便用 shell 方式执行。 """ needs_shell = _has_shell_operators(command) if needs_shell: # 含 shell 操作符或为多行命令:先检查武器级危险命令 if _command_has_dangerous_cmds(command): raise ValueError("命令包含不允许的危险命令(sh/bash/dd/mkfs 等),请手动执行") # 多行或含操作符的命令:按行分割后,对每行逐段检查,取最高权限模式 lines = command.split("\n") if "\n" in command else [command] overall_mode = "read" overall_approval = False first_binary = None first_segment = "" for line in lines: line = line.strip() if not line or line.startswith("#"): continue segments = _split_into_commands(line) for segment in segments: # 检测 kubectl 命令 if segment.startswith("kubectl ") or segment == "kubectl": try: kdec = classify_kubectl_command(segment) except (ValueError, IndexError) as exc: raise ValueError(str(exc)) if first_binary is None: first_binary = "kubectl" first_segment = segment if kdec.mode == "write": overall_mode = "write" overall_approval = True continue try: seg_parts = shlex.split(segment) except ValueError: continue if not seg_parts: continue # 跳过 VAR=value 赋值前缀(含 export) idx = 0 while idx < len(seg_parts): if "=" in seg_parts[idx] and not seg_parts[idx].startswith("-"): idx += 1 elif seg_parts[idx] == "export": idx += 1 else: break if idx >= len(seg_parts): continue # 纯赋值段,跳过 binary = seg_parts[idx] check_parts = seg_parts[idx:] if first_binary is None: first_binary = binary if binary not in SHELL_READ_ONLY_COMMANDS and binary not in SHELL_WRITE_COMMANDS: raise ValueError(f"不允许执行该命令: {binary}") if binary in SHELL_WRITE_COMMANDS: allowed = SHELL_WRITE_COMMANDS[binary] if allowed is None: overall_mode = "write" overall_approval = True else: verb = _first_non_flag(check_parts[1:]) if verb is not None and verb in allowed: overall_mode = "write" overall_approval = True elif binary in SHELL_READ_ONLY_COMMANDS: ro_allowed = SHELL_READ_ONLY_COMMANDS[binary] if ro_allowed is None or (verb is not None and verb in ro_allowed): pass # 该段是只读,不改变 overall else: raise ValueError(f"命令 {binary} 不支持该子命令: {verb}") else: raise ValueError(f"命令 {binary} 不支持该子命令: {verb}") if first_binary is None: raise ValueError("命令不能为空") return CommandDecision(overall_mode, overall_approval, first_binary, [command], needs_shell=True) parts = parse_shell_command(command) binary = parts[0] # 支持 VAR=value command 语法 start_idx = 0 while start_idx < len(parts) and "=" in parts[start_idx] and not parts[start_idx].startswith("-"): start_idx += 1 if start_idx > 0: if start_idx < len(parts): binary = parts[start_idx] effective_parts = parts[start_idx:] else: # 纯赋值行如 LOG_DIR=/tmp/log — 放行 return CommandDecision("read", False, parts[0], parts[1:]) verb = _first_non_flag(effective_parts[1:]) else: effective_parts = parts 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, effective_parts[1:]) # etcdctl: 单段式或两段式命令,verb/subverb 均跳过全局 flag (--endpoints 等) # member/alarm/auth 等同时有只读和写子命令,需按 (verb, subverb) 组合精确判断 if binary == "etcdctl": non_flags = [a for a in effective_parts[1:] if not a.startswith("-")] ev = non_flags[0].lower() if non_flags else None esub = non_flags[1].lower() if len(non_flags) > 1 else None # 单段式 verb if ev in _ETCDCTL_READ_VERBS: return CommandDecision("read", False, ev, effective_parts[1:]) if ev in _ETCDCTL_WRITE_VERBS: return CommandDecision("write", True, ev, effective_parts[1:]) # 两段式: 先查写子命令 (精确匹配 subverb),再查只读子命令 if ev in _ETCDCTL_WRITE_SUBVERBS and esub in _ETCDCTL_WRITE_SUBVERBS[ev]: return CommandDecision("write", True, ev, effective_parts[1:]) if ev in _ETCDCTL_READ_SUBVERBS: if esub is None or esub in _ETCDCTL_READ_SUBVERBS[ev]: return CommandDecision("read", False, ev, effective_parts[1:]) raise ValueError(f"etcdctl {ev} 不支持子命令: {esub}") raise ValueError(f"etcdctl 不支持该子命令: {ev}") 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 effective_parts[1:]): raise ValueError("ip 命令包含写操作,请使用只读形式如 'ip addr show'") return CommandDecision("read", False, binary, effective_parts[1:]) raise ValueError(f"不允许执行该命令: {binary}") def classify_agent_command(command: str) -> CommandDecision: """统一分类 Agent 命令:kubectl 或白名单系统诊断命令。""" stripped = (command or "").strip() # 检测 VAR=value kubectl 语法(有变量赋值时) rest = stripped while True: try: first = rest.split(None, 1)[0] except IndexError: break if "=" in first and not first.startswith("-"): idx = rest.index("=") # 必须是合法变量赋值 VAR=value parts_split = rest.split(None, 1) if len(parts_split) < 2: break rest = parts_split[1] elif first == "export": parts_split = rest.split(None, 1) if len(parts_split) < 2: break rest = parts_split[1] else: break if rest.startswith("kubectl ") or rest == "kubectl": return classify_kubectl_command(rest) 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=True 执行,其他用 subprocess_exec 直接执行。 """ decision = classify_agent_command(command) parts = _parse_command(command) # 支持 VAR=value command 语法,跳过赋值找实际 binary cmd_idx = 0 while cmd_idx < len(parts) and "=" in parts[cmd_idx] and not parts[cmd_idx].startswith("-"): cmd_idx += 1 if cmd_idx > 0 and cmd_idx < len(parts): parts = parts[cmd_idx:] if parts[0] == "kubectl": executable = KUBECTL args = decision.args else: executable = parts[0] args = parts[1:] if decision.needs_shell: proc = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) else: 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: """执行单条 kubectl 命令 (兼容旧接口)。""" decision = classify_kubectl_command(command) proc = await asyncio.create_subprocess_exec( KUBECTL, *decision.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)", }