Files
k8smanager-cli/backend/agent_tools.py
T
Your Name e26a3e559f feat: 支持 shell 变量定义(VAR=value command)、export、多行命令
完整 shell 能力支持:
- VAR=value command 语法(自动跳过赋值检测实际 binary)
- export VAR=value 支持(export 加入只读白名单)
- 多行命令(\n 分割后逐段检查)
- && || ; 等连接符的武器级命令检测
- _split_into_commands 重写:使用 while 索引遍历,正确处理 && / || / |
- _command_has_dangerous_cmds 跳过 VAR=value 和 export 前缀
- classify_agent_command 检测 VAR=value kubectl 语法路由到 kubectl 分类器
- execute_command 支持变量赋值后的 binary 定位
- 新增 shell 内置白名单:echo printf cd pwd export source . sleep timeout
  nohup unset shift return local readonly trap type command set shopt eval
  exec alias unalias declare typeset read mapfile
- 白名单只读命令新增:timeout、nohup、sleep 等
2026-07-25 16:25:41 +08:00

577 lines
24 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 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)",
}