
AI agent 在真实业务中开始承担越来越多“主动决策”的工作之后安全检查的对象不再只是代码仓库里的源代码。一个 agent 可以调用外部工具、读写文件、访问网络、执行命令而这些能力往往来自 MCP 服务器或者被封装成可复用的 agent skill。传统 SAST 扫描器盯着函数调用和数据流看却没有回答一个问题这个 agent 到底被授权做了什么它暴露了多少能力给未知输入。安全扫描器Security scanner for AI agents, MCP servers and agent skills要解决的正是这套新问题审计 agent 的权限边界、工具暴露面、指令注入风险和可执行资产的供应链风险。本文围绕“AI agent、MCP 服务器、agent skills 的安全扫描”这条主线展开。先说明为什么要独立分层扫描再给出一套可落地的扫描器数据模型和最小 Python 实现然后把它接入 CI/CD 作为质量门槛最后整理排查路径和生产基线。适合正在搭建 AI 应用平台的研发、运维和安全同学阅读也适合对 MCP 机制感兴趣的开发者作为上手参考。1. 为什么 AI 代理需要独立的安全扫描层1.1 从传统代码扫描到智能体安全扫描的差异传统安全扫描聚焦在代码缺陷上比如 SQL 注入、XSS、命令拼接、硬编码密钥。它的对象是“代码逻辑”判断依据是语法、数据流和已知漏洞模式。AI agent 场景有一个本质区别核心逻辑由模型动态生成开发者无法在提交代码时确定模型最终会执行哪条工具调用路径。代码里可能根本不存在“注入点”因为危险动作由模型根据上下文自行决定。安全扫描器转向“能力审计”和“暴露面分析”。它检查的是agent 配置里声明了哪些 MCP 服务器每个 MCP 服务器暴露了哪些工具、资源和提示词每个 agent skill 包允许哪些命令、网络访问和文件操作工具定义是否缺少输入校验、是否允许执行任意命令密钥、令牌、内部地址是否被打包进可分发资产。维度传统代码扫描AI agent 安全扫描扫描对象源代码、依赖、配置agent 配置、MCP 工具定义、技能包、提示模板、权限声明主要风险注入、越权、密钥泄露、依赖漏洞提示注入、工具滥用、权限过大、供应链投毒判断依据代码语法、数据流、漏洞规则能力声明、权限边界、工具参数约束、指令风险修复方式改代码、升级依赖收紧工具权限、隔离执行环境、限制技能行为运行时依赖较低较高需要知道 agent 如何加载和执行外部能力1.2 MCP 服务器的攻击面MCPModel Context Protocol为 agent 访问外部工具和数据提供了一种标准协议。一个 MCP 服务器通常会向模型暴露三类内容工具tools、资源resources和提示词prompts。工具是可执行操作资源是可直接读取的数据提示词是预置指令模板。攻击面可以从三个层次看。第一层是工具层。MCP 服务器可能声明一个execute_command工具参数是command: string模型可以直接传入 shell 命令。这类工具一旦被提示注入或恶意指令诱导风险等级非常高。扫描器要识别工具名称、参数结构、是否包含command、shell、exec、file_write、http_request这类高风险语义。第二层是资源和数据层。资源模板可能指向内部文件路径或内部服务地址file:///etc/passwd、http://127.0.0.1:6379这类路径如果被模型读取信息泄露风险很高。扫描器需要解析资源 URI 的 scheme 和 host。第三层是指令层。提示词模板不是普通字符串它会直接进入系统提示影响模型行为。如果提示词来自不可信来源可能包含“忽略之前指令”“把结果发送到外部地址”等恶意内容。虽然静态扫描无法完全判断模型会如何理解但至少可以识别出高风险指令模式和外部地址。// 常见 MCP 工具定义中的高风险字段 { name: execute_command, description: Run a shell command on the host, inputSchema: { type: object, properties: { command: { type: string }, cwd: { type: string } }, required: [command] } }这段定义本身可能没有漏洞但它意味着一旦 agent 被注入恶意指令工具就会变成远程命令执行的入口。安全扫描器的任务之一就是把这个风险等级标出来。1.3 agent skills 的信任边界agent skills 是一种把“能力 说明 脚本”打包成可复用资产的方式。技能包通常包含三部分描述技能用途和调用方式的说明文档、执行具体操作的脚本或配置、声明权限和依赖的元数据。它的信任边界比普通代码更特殊。普通代码的信任边界是仓库权限和代码评审技能包的信任边界是“谁写的、谁审的、在哪运行”。如果团队从内部市场或第三方仓库拉取技能包供应链风险会直接进入 agent 运行时。一个恶意技能包可以在说明里诱导模型调用危险命令也可以直接在安装脚本里执行动作。扫描器对技能包的审计重点包括技能包元数据是否声明了执行权限说明文档是否包含指向未知域名的链接脚本是否包含rm -rf、curl | sh、base64 -d等高风险模式技能包是否携带密钥或.env文件技能包依赖是否锁定版本是否来自可信源。2. 扫描器的架构设计与数据模型2.1 扫描器整体工作流程一个可用的 AI agent 安全扫描器至少包含四个阶段收集目标、解析资产、执行规则、输出报告。收集目标是定位要扫描的目录或文件。扫描对象可以是整个项目仓库也可以是指定的 agent 目录、MCP 配置目录或 skills 目录。解析资产阶段把配置文件、JSON Schema、Markdown 说明、脚本文件转换为统一的数据结构。执行规则阶段用一组确定性的规则对数据结构做判断产出风险条目。输出报告阶段把风险条目格式化为 JSON、Markdown 或 SARIF方便集成到 CI/CD 和 IDE。这个流程与传统扫描器类似但关键差异在“资产解析”这一步。扫描器必须先理解 MCP 工具的结构、技能包的元数据才能把风险规则命中在正确的位置。跳过解析阶段直接用正则匹配全部文本会产生大量误报。2.2 用数据结构描述“AI 资产”为了让扫描规则有稳定的执行基础建议先用 Python dataclass 定义核心数据模型。下面是一个最小可用的模型覆盖 MCP 服务器和技能包。# models.py from dataclasses import dataclass, field from typing import Optional dataclass class ToolDefinition: name: str description: str input_schema: dict field(default_factorydict) annotations: dict field(default_factorydict) def parameters(self) - dict: return self.input_schema.get(properties, {}) dataclass class MCPResource: uri: str name: str mime_type: str dataclass class MCPPrompt: name: str description: str arguments: list field(default_factorylist) dataclass class MCPServerDeclaration: name: str command: str args: list field(default_factorylist) env: dict field(default_factorydict) config_path: str tools: list field(default_factorylist) resources: list field(default_factorylist) prompts: list field(default_factorylist) dataclass class SkillAction: command: str allow_list: list field(default_factorylist) network_access: bool False filesystem_write: bool False dataclass class SkillPackage: name: str version: str path: str description: str allowed_commands: list field(default_factorylist) actions: list field(default_factorylist) files: list field(default_factorylist)这几个数据结构决定了扫描规则能读取哪些信息。比如规则要判断一个 MCP 工具是否允许任意命令执行只需要读取ToolDefinition.name和parameters()。规则要判断技能包是否联网只需要读取SkillPackage.actions里的network_access字段。2.3 风险分级扫描结果不能只给一个“有问题/没问题”的结论。需要风险分级否则团队无法确定修复优先级。这里建议分四级。级别名称典型场景处理策略L1阻断允许任意命令执行、包含明文密钥、技能包来源不可信CI 必须失败禁止合并L2高MCP 工具可访问内网地址、文件写权限未限制、提示词指向外部域名需安全评审后合并L3中工具描述缺少输入约束、依赖版本未固定记录风险限期整改L4提示权限声明不完整、缺少安全说明线上报告可见即可分级要尽量可判定不能依赖人的主观感觉。allow arbitrary command execution可以翻译成“工具名包含execute或shell且参数中存在未约束的命令字符串”这样的规则。“来源不可信”可以翻译成“技能包元数据中没有source字段或source不在可信域名列表中”。2.4 为什么先建立数据模型再写检测规则如果直接对 MCP 配置文件做字符串匹配规则会非常脆弱。比如工具名从exec_command改成run_cmd正则匹配就失效了。如果先解析成结构体规则写的是“读取所有 tools 的 name只要包含exec或cmd就提示高险”后续字段改名只需要调整一条解析映射。另一个原因是方便扩展。今天扫描 MCP 工具明天要扫描 MCP 资源、后天要扫描技能包只要数据模型稳定增加规则的成本会明显下降。3. 用 Python 实现一个最小可用安全扫描器3.1 环境准备下面的示例用一个独立的 Python 项目演示不依赖复杂框架。实际项目落地时可以根据团队技术栈改写成 Go、Java 或 Rust扫描器的核心思路是一样的。mkdir ai-agent-security-scanner cd ai-agent-security-scanner python3 -m venv .venv source .venv/bin/activate pip install pyyaml jsonschemapyyaml用于解析 YAML 配置jsonschema用于校验 MCP 工具参数。前者是为了处理技能包和扫描配置后者是为了在解析工具 schema 时避免结构异常导致规则崩溃。3.2 实现规则引擎规则引擎的核心是输入一个数据模型输出若干风险条目。下面定义一个基础规则接口和两条可执行规则。# rules.py from dataclasses import dataclass from typing import List from models import MCPServerDeclaration, SkillPackage dataclass class RiskFinding: rule_id: str severity: str message: str file_path: str target: str class BaseRule: rule_id BASE severity L4 def check_servers(self, servers) - List[RiskFinding]: return [] def check_skills(self, skills: List[SkillPackage]) - List[RiskFinding]: return [] class ArbitraryCommandToolRule(BaseRule): 检测 MCP 工具是否允许任意命令执行或参数中暴露命令入口。 rule_id MCP-001 severity L1 dangerous_keywords [execute, exec, shell, command, cmd] def check_servers(self, servers) - List[RiskFinding]: findings [] for server in servers: for tool in server.tools: name tool.name.lower() if any(k in name for k in self.dangerous_keywords): params tool.parameters() for param_name, param_schema in params.items(): if any(k in param_name.lower() for k in self.dangerous_keywords): findings.append(RiskFinding( rule_idself.rule_id, severityself.severity, messagefTool {tool.name} 允许通过参数 {param_name} 执行命令, file_pathserver.config_path, targetserver.name / tool.name )) return findings class SecretFileRule(BaseRule): 检测技能包目录下是否出现密钥文件或环境变量文件。 rule_id SKILL-002 severity L1 secret_filename_keywords [.env, id_rsa, credential, secret, token] def check_skills(self, skills: List[SkillPackage]) - List[RiskFinding]: findings [] for skill in skills: for file_path in skill.files: lower_path file_path.lower() if any(k in lower_path for k in self.secret_filename_keywords): findings.append(RiskFinding( rule_idself.rule_id, severityself.severity, messagef技能包包含疑似密钥文件: {file_path}, file_pathfile_path, targetskill.name )) return findings规则接口本身不复杂关键是每个规则都只访问数据模型不直接处理原始字符串。这样排查问题时可以单独测试某一个规则不会因为文件格式变化导致整条规则链崩溃。3.3 解析 MCP 服务器配置MCP 服务器声明可能出现在不同位置。常见场景是根目录下的mcp.json、.mcp.json或者 agent 平台内置的servers配置。下面用一份通用示例说明解析思路。{ servers: [ { name: local-shell, command: python, args: [-m, mcp_server_shell], env: { ALLOWED_PATH: /tmp/work } }, { name: internal-db, command: python, args: [-m, mcp_server_db], env: { DB_HOST: 127.0.0.1 } } ] }解析器把 JSON 转成MCPServerDeclaration对象然后根据工具定义填充tools。工具定义往往通过tools/list接口在运行时获取但安全扫描器通常只做静态扫描因此可以依赖一份导出的工具描述文件。如果项目中没有工具描述文件扫描器至少需要检查服务器本身。# mcp_parser.py import json from pathlib import Path from models import MCPServerDeclaration, ToolDefinition, MCPResource, MCPPrompt def load_mcp_servers(path: str) - list: content Path(path).read_text(encodingutf-8) data json.loads(content) servers [] for item in data.get(servers, []): server MCPServerDeclaration( nameitem.get(name, ), commanditem.get(command, ), argsitem.get(args, []), envitem.get(env, {}), config_pathpath, ) tools_export item.get(tools, []) for tool_data in tools_export: server.tools.append(ToolDefinition( nametool_data.get(name, ), descriptiontool_data.get(description, ), input_schematool_data.get(inputSchema, {}), )) servers.append(server) return servers这里要注意tools字段不是 MCP 协议标准配置而是扫描器使用的“工具描述导出文件”约定。实际项目里可以通过脚本从 MCP 服务器调用tools/list后导出 JSON作为扫描输入。3.4 扫描 agent skills 目录技能包目录结构可以按自己的约定设计。下面是一种通用结构skills/ fetch-url/ SKILL.md skill.yaml fetch.py local-db-query/ SKILL.md skill.yaml query.pyskill.yaml用于声明技能名称、权限和命令白名单。# skills/fetch-url/skill.yaml name: fetch-url version: 1.0.0 description: Fetch URL content and extract text permissions: network: true filesystem_write: false commands: - python fetch.py {url}扫描器解析skill.yaml后把该目录下的所有文件加入SkillPackage.files然后交给规则检查。# skill_parser.py import os import yaml from pathlib import Path from models import SkillPackage def load_skill_packages(base_dir: str) - list: packages [] for root, dirs, files in os.walk(base_dir): if skill.yaml not in files: continue meta_path Path(root) / skill.yaml meta yaml.safe_load(meta_path.read_text(encodingutf-8)) skill SkillPackage( namemeta.get(name, root), versionmeta.get(version, ), pathroot, descriptionmeta.get(description, ), allowed_commandsmeta.get(commands, []), ) skill.actions.append({ network: meta.get(permissions, {}).get(network, False), filesystem_write: meta.get(permissions, {}).get(filesystem_write, False), }) for file_name in files: skill.files.append(str((Path(root) / file_name).resolve())) packages.append(skill) return packages这里有个容易出错的地方filesystem_write: false只是声明扫描器无法证明脚本实际不会写入文件。因此技能包扫描只是第一道关卡不是运行时保证。对于生产环境应该配合沙箱或 seccomp 策略。3.5 生成评分和报告规则执行完成后把所有RiskFinding汇总到一个报告里。报告不追求复杂的展示关键是能直接被 CI 解析。# scanner.py import json import sys from mcp_parser import load_mcp_servers from skill_parser import load_skill_packages from rules import ArbitraryCommandToolRule, SecretFileRule def scan(project_path: str) - dict: findings [] rules [ArbitraryCommandToolRule(), SecretFileRule()] mcp_files [project_path /mcp.json, project_path /.mcp.json] servers [] for mcp_file in mcp_files: if os.path.exists(mcp_file): servers.extend(load_mcp_servers(mcp_file)) skills_dir os.path.join(project_path, skills) skills load_skill_packages(skills_dir) if os.path.exists(skills_dir) else [] for rule in rules: findings.extend(rule.check_servers(servers)) findings.extend(rule.check_skills(skills)) finding_dicts [finding.__dict__ for finding in findings] return { summary: { total: len(finding_dicts), blocking: len([f for f in finding_dicts if f[severity] L1]), high: len([f for f in finding_dicts if f[severity] L2]), }, findings: finding_dicts, } if __name__ __main__: project_path sys.argv[1] if len(sys.argv) 1 else . result scan(project_path) print(json.dumps(result, ensure_asciiFalse, indent2))一个简单报告输出如下{ summary: { total: 2, blocking: 1, high: 0 }, findings: [ { rule_id: MCP-001, severity: L1, message: Tool execute_command 允许通过参数 command 执行命令, file_path: /repo/mcp.json, target: local-shell/execute_command }, { rule_id: SKILL-002, severity: L1, message: 技能包包含疑似密钥文件: /repo/skills/fetch-url/.env, file_path: /repo/skills/fetch-url/.env, target: fetch-url } ] }这样一份报告可以直接合并到 MR 评论里也可以上传为 CI artifact。blocking数量一旦大于 0流水线就应该失败。4. 将扫描器接入 CI/CD形成可执行门槛4.1 常用命令行参数设计只有扫描器没有流程门槛扫描结果很快会变成没人看的报告。建议从一开始就设计好命令行参数便于接入各种 CI 系统。python scanner.py scan \ --path ./agents \ --block-level L1 \ --ignore-file .security-ignore.yml \ --format json \ --debug参数含义参数作用示例--path指定要扫描的目录./agents--block-level达到该级别即阻断 CIL1--ignore-file指定忽略规则文件.security-ignore.yml--format报告输出格式json、markdown、sarif--debug输出解析日志和内部变量无--block-level的设计很关键。它让不同团队按自己的承受能力设置门槛平台组可以严格要求 L1 阻断独立小型项目临时放宽到 L2但报告仍然保留。4.2 在 GitHub Actions 中执行下面是一段 CI 集成示例在每次 Pull Request 上运行扫描并上传报告。name: agent-security-scan on: pull_request: paths: - agents/** - skills/** - mcp.json - .github/workflows/agent-security-scan.yml jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.11 - name: Install dependencies run: pip install pyyaml jsonschema - name: Run security scanner run: python scanner.py scan --path . --block-level L1 --format json --debug - name: Upload report uses: actions/upload-artifactv4 with: name: agent-security-report path: report.json触发路径要尽量精确。只影响agents/、skills/和 MCP 配置文件的改动才需要扫描否则每轮 CI 都扫描全部仓库时间和噪声成本都会上升。4.3 用 ignore 配置降低噪声任何安全扫描器都会产生误报。与其在代码里硬编码跳过不如提供一份显式的忽略文件保留审计记录。# .security-ignore.yml rules: - rule_id: MCP-001 targets: - local-shell/execute_command reason: execute_command 只允许在本地开发容器中运行容器不挂载生产密钥 expires: 2026-01-01忽略文件包含三个关键点目标 ID、原因、过期时间。给“原因”和“过期时间”的目的是让忽略决策可追溯。没有过期时间的忽略规则容易变成永久例外。5. 实际项目中的排错与误报治理5.1 高频问题排查表扫描器接入后最常见的不是扫描器本身报错而是解析失败和误报。问题现象常见原因检查方式处理建议MCP 配置解析后工具列表为空配置里没有tools导出字段或工具描述文件未生成用--debug打印加载后的MCPServerDeclaration对象增加导出流程扫描前调用tools/list生成描述文件技能包目录没有被扫描扫描器只查找skill.yaml目录命名不一致检查目录下是否存在skill.yaml文件大小写是否正确统一技能包固定文件名区分入口文件与说明文档密钥扫描误报测试 token规则对整个目录的.env文件敏感查看SkillPackage.files中记录的路径确认是否为测试 fixture在.security-ignore.yml中忽略测试目录或让种子数据放在fixtures/目录CI 无法运行扫描器因为依赖下载失败内网环境无法访问公共 Python 源检查流水线日志中的 pip 错误使用内部镜像源或在依赖安装阶段前缓存依赖扫描器版本和 MCP 配置结构不匹配协议或平台升级后字段名发生调整用--debug导出解析后的数据模型保持在 CI 中锁定扫描器版本升级时先验证全量规则5.2 误报治理的四个步骤第一步先确认规则命中的对象是否真实存在于目标目录。很多误报来自模板目录、测试目录和历史遗留文件。第二步判断规则逻辑是否过时。比如规则把http一律判为风险但业务中https://也可能被误写。需要调整规则而不是直接忽略。第三步分析数据模型是否解析正确。误报常常不是规则写错而是解析器把描述文本塞进了错误字段导致规则命中了错误内容。第四步用忽略文件记录经过人工确认的例外。忽略时写清原因和过期时间每周组织一次 review。5.3 日志与调试扫描器调试时建议保留三层日志解析日志、规则命中日志、排除日志。--debug开关负责打开解析日志展示每个输入文件被解析成了什么结构--list-rules输出当前启用的规则 ID 和严重级别排除日志会在忽略规则生效时打印原因避免开发者误认为“扫描器坏了”。python scanner.py scan --path ./demo --list-rules python scanner.py scan --path ./demo --debug第一行用于确认规则集合是否符合预期第二行用于查看具体文件被解析后的数据模型。实际排查顺序是先确认输入文件存在再确认解析结果不为空再确认规则命中逻辑最后确认忽略规则是否干扰。6. 生产环境落地的安全基线6.1 学习环境、开发环境与生产环境的差异同一个扫描器在不同环境里的用途和严格度要区分开。环境主要目的建议做法学习环境快速跑通开发流程扫描结果仅展示不阻断规则可放宽开发环境尽早发现高风险配置开启 L1、L2 阻断L3 提示测试环境验证安全策略本身加入密钥轮换、模拟注入攻击等验证用例生产环境防止风险变更上线强制阻断扫描器版本固定报告归档至少 180 天扫描器只是生产环境的一层闸门不能替代运行时隔离。生产环境还需要考虑日志、监控、回滚、权限边界和异常处理。比如 MCP 服务器运行在独立容器里文件系统只读挂载网络只允许白名单出口这些是扫描器无法静态保证的。6.2 最小可行安全基线清单发布前检查清单MCP 服务器是否只声明了最小必要工具工具参数是否避免“任意字符串命令”形式MCP 服务器运行账号是否具备写权限技能包是否来自可信源依赖版本是否锁定技能包目录是否包含密钥文件提示词模板是否包含外部链接或“忽略指令”类内容网络出口是否限制为白名单文件系统写入路径是否限制在临时目录CI 是否强制运行扫描器报告是否保存忽略规则是否有过期时间是否经过评审。这份清单可以贴到团队发布规范里。它不是安全最佳实践的全部却是最容易落地的第一层。6.3 扩展方向当前实现是静态扫描能力边界很清楚它能看到声明看不到运行时真实调用。后续扩展可以沿下面几个方向发展。一是运行时审计。给 MCP 工具调用加一层代理记录模型实际调用了哪些工具、传入了哪些参数形成调用日志再与静态扫描结果对比发现“声明之外的实际行为”。二是沙箱验证。把 MCP 服务器或技能包放入受限沙箱运行观察它尝试访问的地址、文件和进程用动态行为补充静态规则判断。三是供应链签名。技能包和 MCP 服务器插件来自不同维护者如果统一增加签名校验扫描器可以把“来源可信”从不可判定项变成可判定项。四是策略即代码。把“不能使用任意命令工具”“不能访问内网地址”“技能包不能携带密钥”写成策略文件扫描器只是策略执行器。这样规则可以独立于扫描器代码维护业务侧也更愿意参与评审。6.4 实践建议与下一步AI agent 的安全扫描是一个新事物但它依然遵循老原则最小权限、可信来源、闭环审计。如果你的团队刚开始做不要急着写上百条规则先把 MCP 服务器和技能包的数据模型建好把“任意命令执行”“密钥泄露”“来源不可信”这三点查清楚接入 CI 后观察一到两周误报率再逐步扩充规则库。从一个最小扫描器开始让每一次工具调用、每一个技能包、每一段提示词都在上线前被检查一遍是当前成本最低且收益最稳定的安全投入方向。下一步可以把扫描报告接入内部安全平台把风险数据沉淀成趋势分析让安全扫描从一次性工具变成持续治理机制。