【雕虫大技】Agent 动态 Skill 供应链安全加固(三):输出校验与治理闭环实战

一、背景:执行被关进笼子之后,还剩两个问题

前两篇把 skill 供应链的执行前和执行中两个环节都加固了:

  • 阶段一保证内容可信:下载完整性校验(HMAC)、静态 import 扫描、路径防逃逸;
  • 阶段二保证执行受控:进程级软沙箱 + 容器级硬沙箱,恶意脚本跑不出笼子。

但站在供应链的视角看,还差两块:

  1. 结果可信。沙箱只保证脚本跑不出笼子,不保证脚本吐出来的东西是对的。改造前,负责调用脚本的 PdfService 把脚本打印到标准输出(stdout,也就是脚本输出的文本)原样当成可信结果返回。脚本只要输出一段格式错误的 JSON,或者输出太大被截断(比如 JSON 被拦腰切断),调用方毫不知情,原样拿去做报告,下游就会解析出错误的结果。
  2. 治理闭环。谁批准了某个 skill 使用网络权限?哪个版本可以上线?出事了怎么一键停用?这些事前两篇没有管。没有审批和审计,这条链就缺了治理这一环。

阶段三回答这两个问题:输出契约校验 + 治理闭环。

二、整体设计

阶段三的防线落在执行链路的两端:

bash 复制代码
下载(阶段一)→ 物化/扫描(阶段一)→ 治理裁决(阶段三)→ 沙箱执行(阶段二)→ 输出校验(阶段三)→ 审计(阶段三)

两块内容:

  1. 输出契约校验SKILL.md frontmatter 声明 output: text|json,执行后按契约校验,校验失败一律按执行失败处理(fail-closed),不把不可信输出透传给下游。
  2. 治理闭环:四件事,权限审批、版本锁定、隔离与熔断、审计留痕。执行前做裁决,执行后记审计,任何一环出问题都能追溯、能应急。

三、输出契约校验

3.1 契约从哪来

SKILL.md frontmatter 里加一行声明(缺省 text):

bash 复制代码
---
name: pdf
version: 1.0.0
output: text
permissions: []
---

契约是什么

先解释契约这两个字。沙箱负责脚本跑得安不安全,不负责脚本输出的东西长什么样。要让下游放心使用输出,就得在运行前约定输出应该是什么形态,这个约定就是契约。契约放在 SKILL.md 的 frontmatter 里,因为它是 skill 自带的元数据,skill 作者在上传时就要写清楚。

frontmatter 里四个字段

frontmatter 里四个字段,各有各的用途:

字段 用途 缺省时的安全值
name skill 名,用于展示和归属 空字符串
version 版本号,审批和版本锁定都按 skill@version 匹配 0.0.0
output 输出契约,text 表示纯文本,json 表示 JSON text
permissions 申请的权限,逐项走审批 空集,即不申请任何权限

缺省值的安全原则

缺省值遵循一个原则:缺了什么就往最小权限、最保守的方向兜底。逐项看:

(1)版本缺失按 0.0.0,审批记录也按 0.0.0 记,不会因为版本缺失绕过审批;

(2)output 缺失按 text,没有声明 json 契约就不做 JSON 强校验;

(3)permissions 缺失按空集,不申请权限就不需要审批。

SkillMetadata:解析实现

SkillMetadata 就是解析这份元数据的类,代码分四段看:

  1. FRONTMATTER 正则负责切出 frontmatter 段落,即两个 --- 之间的 YAML,没有 frontmatter 就返回空元数据;
  2. field 读取单个标量字段,顺手做三件清理:strip() 去掉首尾空白、去掉行内注释、去掉引号,保证拿到的是干净的值;
  3. parsePermissions 兼容两种写法,行内数组 permissions: [network, subprocess] 和列表形式,统一转小写,保证和审批表里的权限名比较一致;
  4. 构造器把四个字段归一化后存成不可变对象,对外只读。
bash 复制代码
package com.yangtze.bankwarning.ai.security;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import lombok.Getter;

/**
 * Skill 元数据解析器(阶段三 · 治理闭环)。
 *
 * 从 SKILL.md 的 YAML frontmatter 中解析 name / version / output / permissions,
 * 供权限审批、版本锁定与输出契约校验使用。
 * 缺失的字段有安全默认值:version 视为 0.0.0,output 视为 text,permissions 为空。
 */
@Getter
public final class SkillMetadata {

    /** frontmatter 段落:--- 包裹的 YAML */
    private static final Pattern FRONTMATTER =
            Pattern.compile("^---\\s*\\R(.*?)\\R---\\s*", Pattern.DOTALL);

    public static final String DEFAULT_VERSION = "0.0.0";
    public static final String OUTPUT_TEXT = "text";
    public static final String OUTPUT_JSON = "json";

    private final String name;
    private final String version;
    private final String output;
    private final Set<String> permissions;

    private SkillMetadata(String name, String version, String output, Set<String> permissions) {
        this.name = name == null ? "" : name.strip();
        this.version = (version == null || version.isBlank()) ? DEFAULT_VERSION : version.strip();
        this.output = (output == null || output.isBlank()) ? OUTPUT_TEXT : output.strip().toLowerCase();
        this.permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
    }

    public static SkillMetadata parse(Path skillDir) {
        if (skillDir == null) {
            return empty();
        }
        Path skillMd = skillDir.resolve("SKILL.md");
        if (!Files.isRegularFile(skillMd)) {
            return empty();
        }
        try {
            return parse(Files.readString(skillMd, StandardCharsets.UTF_8));
        } catch (IOException e) {
            return empty();
        }
    }

    public static SkillMetadata parse(String skillMdContent) {
        if (skillMdContent == null) {
            return empty();
        }
        Matcher fm = FRONTMATTER.matcher(skillMdContent);
        if (!fm.find()) {
            return empty();
        }
        String yaml = fm.group(1);
        return new SkillMetadata(
                field(yaml, "name"),
                field(yaml, "version"),
                field(yaml, "output"),
                parsePermissions(yaml));
    }

    public static SkillMetadata empty() {
        return new SkillMetadata("", DEFAULT_VERSION, OUTPUT_TEXT, Set.of());
    }

    /** 读取单个标量字段(去掉行内注释和首尾引号) */
    private static String field(String yaml, String key) {
        Matcher m = Pattern.compile("^\\s*" + key + "\\s*:\\s*([^\\n\\r#]+)", Pattern.MULTILINE).matcher(yaml);
        if (!m.find()) {
            return "";
        }
        String value = m.group(1).strip();
        int hash = value.indexOf('#');
        if (hash >= 0) {
            value = value.substring(0, hash).strip();
        }
        return value.replaceAll("^[\"']|[\"']$", "");
    }

    /** 解析 permissions:支持行内数组 [a, b] 与列表两种形式 */
    private static Set<String> parsePermissions(String yaml) {
        Set<String> result = new LinkedHashSet<>();
        Matcher inline = Pattern.compile("^\\s*permissions\\s*:\\s*\\[([^\\]]*)\\]", Pattern.MULTILINE).matcher(yaml);
        if (inline.find()) {
            for (String item : inline.group(1).split(",")) {
                String t = item.strip().replaceAll("[\"'\\[\\]]", "");
                if (!t.isEmpty()) {
                    result.add(t.toLowerCase());
                }
            }
            return result;
        }
        Matcher block = Pattern.compile("^\\s*permissions\\s*:\\s*\\R", Pattern.MULTILINE).matcher(yaml);
        if (block.find()) {
            String after = yaml.substring(block.end());
            Matcher item = Pattern.compile("^\\s*-\\s*([^\\n\\r]+)", Pattern.MULTILINE).matcher(after);
            while (item.find()) {
                String t = item.group(1).strip().replaceAll("[\"'\\[\\]]", "");
                if (!t.isEmpty()) {
                    result.add(t.toLowerCase());
                }
            }
        }
        return result;
    }

    public boolean isJsonOutput() {
        return OUTPUT_JSON.equals(output);
    }
}

3.2 校验规则

契约定了,接下来是执行后按契约验收。SkillOutputValidator 是验收员,输入是元数据和沙箱执行结果,输出是 ValidationResult,里面带一个是否通过和可选的原因。

三条规则如下:

  1. 执行未成功,也就是非零退出或超时,直接判定失败。
  2. 输出被截断,也就是超过沙箱上限,直接判定失败。截断意味着只拿到了一半内容,半截数据比没有数据更危险,它可能是被拦腰切断的 JSON,透传下去会让下游解析出错误的结果。
  3. 声明 json 契约时,输出必须非空且能被严格解析。readTree 解析失败,包括输出尾部还混着其他字符,都判定失败,因为带尾巴的输出不是一份干净的 JSON。

为什么一律 fail-closed? 因为这条链路的下游是报告和业务判断,宁可拒绝一次边缘情况,也不能让一份格式非法的输出混进结果。Validator 只负责格式对不对,语义对不对由下游业务校验,这是它的信任边界。

bash 复制代码
package com.yangtze.bankwarning.ai.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

/**
 * Skill 输出契约校验器(阶段三 · 输出校验)。
 *
 * 按 SKILL.md frontmatter 声明的 output 契约校验脚本 stdout:
 *   - text:默认契约,仅要求未超限截断;
 *   - json:要求输出是合法 JSON,且不能为空。
 * 校验失败一律按执行失败处理(fail-closed),绝不把不可信输出透传给下游。
 */
@Component
public class SkillOutputValidator {

    private static final Logger log = LoggerFactory.getLogger(SkillOutputValidator.class);

    private final ObjectMapper objectMapper;

    public SkillOutputValidator(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    /**
     * @param metadata skill 元数据(决定输出契约),可为 null,按 text 处理
     * @param result   沙箱执行结果
     * @return 校验结果
     */
    public ValidationResult validate(SkillMetadata metadata, SkillSandboxExecutor.SandboxResult result) {
        if (result == null || !result.isSuccess()) {
            return ValidationResult.invalid("脚本执行未成功,无法校验输出");
        }
        // 截断的输出不可信,fail-closed
        if (result.isOutputTruncated()) {
            return ValidationResult.invalid("脚本输出超过上限被截断,结果不可信");
        }
        String output = metadata == null ? SkillMetadata.OUTPUT_TEXT : metadata.getOutput();
        if (SkillMetadata.OUTPUT_JSON.equals(output)) {
            String text = result.getStdout() == null ? "" : result.getStdout().strip();
            if (text.isEmpty()) {
                return ValidationResult.invalid("输出为空,不满足 json 契约");
            }
            try {
                objectMapper.readTree(text);
                return ValidationResult.valid();
            } catch (Exception e) {
                log.warn("[skill-output] 输出不是合法 JSON: {}", e.getMessage());
                return ValidationResult.invalid("输出不是合法 JSON: " + e.getMessage());
            }
        }
        return ValidationResult.valid();
    }

    /** 校验结果 */
    public static final class ValidationResult {
        private final boolean valid;
        private final String reason;

        private ValidationResult(boolean valid, String reason) {
            this.valid = valid;
            this.reason = reason;
        }

        public static ValidationResult valid() {
            return new ValidationResult(true, "");
        }

        public static ValidationResult invalid(String reason) {
            return new ValidationResult(false, reason);
        }

        public boolean isValid() {
            return valid;
        }

        public String getReason() {
            return reason;
        }
    }
}

3.3 接入位置

PdfService 在沙箱执行成功之后、返回内容之前调用校验,失败就拒绝并记审计。位置选在这一刻,是为了让校验发生在数据离开可信边界的最后一环,既能看到完整输出,又能挡住它进入下游。这也意味着原来的截断只告警行为被收紧了:截断即失败。

四、治理闭环

4.1 先看全景:执行前裁决,数据来自两处

治理闭环只回答一个问题:一个从 Nacos 下载来的 skill@version 要执行,凭什么放行? 答案是执行前过一道裁决(evaluate),把两处的数据合并成"放行/拒绝";执行后写审计,任何一次放行或拦截都能追溯。

bash 复制代码
skill@version 请求执行
      │
      ▼
┌───────────── evaluate() 裁决 ─────────────┐
│ ① 全局熔断    kill-switch          ← yml  │
│ ② 隔离        quarantined-versions ← yml  │
│              + skill_versions=QUARANTINED ← 数据库 │
│ ③ 版本状态    skill_versions=ACTIVE ← 数据库 │
│ ④ 版本白名单  allowed-versions     ← yml  │
│ ⑤ 权限审批    skill_approvals      ← 数据库 │
│ 任一不过 → 拒绝并记审计                     │
└────────────────────────────────────────────┘
      │ 全部通过
      ▼
沙箱执行 → 输出校验 → 审计留痕(skill_audit_log)

用文字把这条链路过一遍:

  1. skill 从 Nacos 下载并按版本物化到本地缓存(.skills-cache///,新版本自动激活为 ACTIVE),skill 本体在这里(阶段一);
  2. SKILL.md 声明 permissions,这只是提出申请;
  3. 下载时按 SKILL.md 声明的权限自动生成 PENDING 待审批记录,管理员通过管理接口批准,状态变为 APPROVED 并记录操作人与时间;
  4. 执行时 evaluate() 每次查表核对:版本状态(skill_versions)、版本在不在允许清单、权限有没有审批记录,批准后立即生效,无需重启;
  5. 通过后沙箱执行、输出校验,最后写 skill_audit_log。

后面所有小节都围绕这张图:4.2 讲"表"和"yml"怎么分工,4.3 给五道闸画一张地图,4.4--4.6 讲数据库里的三张表,4.7 讲 yml 的三个开关,4.8 用 evaluate() 的完整代码收口。

4.2 数据从哪来:两类策略源

图里已经出现两类来源:yml 配置和数据库表。它们不是两套重复治理,分工原则只有一条:

  • 数据库管需要持久化、要审计、要被管理端改的数据:谁批了什么权限(skill_approvals)、本地有哪些版本和什么状态(skill_versions)、每次裁决的底账(skill_audit_log);
  • yml 管运维策略和应急开关:全局熔断(kill-switch)、版本白名单(allowed-versions)、隔离清单(quarantined-versions)。它们必须"数据库不可用时也能生效"------应急开关如果依赖数据库,数据库故障时反而失灵。

落到数据上:

数据 存在哪 谁写
skill 本体(SKILL.md + scripts) Nacos(发布侧)/ classpath / 本地缓存 .skills-cache(消费侧) Nacos 上传、下载物化
权限审批记录 skill_approvals 表 下载时自动生成待审批,管理接口批准/驳回
版本记录与状态(多版本共存) skill_versions 表 下载自动注册并激活,管理接口切换/隔离/删除
版本批准清单 / 熔断 application.yml 配置 运维修改配置
审计记录 skill_audit_log 表 程序自动写入

一句话记住:要审计、要管理端改 → 数据库;要应急、要不依赖数据库 → 配置。 两者最终在 evaluate() 汇合。

4.3 裁决五道闸:先看地图

evaluate() 按固定顺序过五道闸:

顺序 检查 数据源 类型
全局熔断 kill-switch 是否开启 application.yml 应急
是否命中隔离(yml 清单 或 skill_versions=QUARANTINED) yml + 数据库 应急
版本状态是否为 ACTIVE skill_versions 表 常态
版本是否在批准清单 allowed-versions 内 application.yml 常态
声明的权限是否都有 APPROVED 审批记录 skill_approvals 表 常态

为什么顺序必须固定:越靠前的检查越紧急,熔断、隔离和版本状态是应急动作,必须最先生效;版本白名单和权限是常态化策略,排在后面。顺序乱了,可能出现版本不在白名单但权限已批准这种先放后拦的错位。

这张表就是全章的目录:⑤ 号闸的数据在 4.4(权限审批),审计底账在 4.5,③ 号闸背后的多版本机制在 4.6,①②④ 三个配置开关在 4.7,最后 4.8 把五道闸合成 evaluate() 一个方法。

4.4 权限审批(⑤ 号闸):skill_approvals

权限不是声明了就能用。 SKILL.md 声明 permissions 只是提出申请,能不能用取决于 skill_approvals 表里有没有审批记录。这里最容易误解的是粒度:审批表里的一行是一个权限,不是整个 skill ------skill 本体(SKILL.md + 脚本)不在这张表里,它走 Nacos/缓存分发;skill 声明了几个权限,表里就有几行,管理员可以只批其中一部分。执行前把声明的权限和已审批权限逐一比对,缺一个就拒绝(fail-closed);灰度期可以把 fail-on-unapproved-permission 关掉,改成只告警放行。

审批表和审计表都由 JdbcSkillApprovalStore 启动时自举建表(CREATE TABLE IF NOT EXISTS)。审批表:

bash 复制代码
CREATE TABLE IF NOT EXISTS skill_approvals (
    id BIGSERIAL PRIMARY KEY,
    skill_name VARCHAR(128) NOT NULL,
    version VARCHAR(64) NOT NULL,
    permission VARCHAR(64) NOT NULL,
    approved BOOLEAN NOT NULL DEFAULT FALSE,
    status VARCHAR(20) NOT NULL DEFAULT 'APPROVED',
    requested_by VARCHAR(128),
    reviewed_by VARCHAR(128),
    reviewed_at TIMESTAMP,
    comment TEXT,
    approved_by VARCHAR(128),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(skill_name, version, permission)
);

几个字段意思如下:

  • status 是审批状态机:PENDING 待审批,APPROVED 通过,REJECTED 驳回,重新提交后回到 PENDING;
  • approved 布尔列与 status 冗余,是为了兼容早期版本,判断以 status 为准;
  • requested_byreviewed_byreviewed_atcomment 记录申请人和审批人、时间、意见,审计时能还原谁在什么时候批了什么;
  • UNIQUE(skill_name, version, permission) 保证同一权限不会出现两行,这也是 createPending 幂等的前提。

光看字段还不够直观,拿真实数据举个例子。假设 skill-creatorSKILL.md 里声明了 permissions: [network, subprocess],下载后表里会生成两行:

id skill_name version permission approved status requested_by reviewed_by reviewed_at comment approved_by created_at
1 skill-creator 1.2.0 network true APPROVED nacos-sync admin 2026-08-12 10:23:00 合规,用于拉取模板 admin 2026-08-12 09:00:00
2 skill-creator 1.2.0 subprocess false PENDING nacos-sync NULL NULL NULL NULL 2026-08-12 09:00:00

第一行已被管理员批准,第二行还是 PENDING。执行时 evaluate()SKILL.md 声明的权限逐行比对,subprocess 没有 APPROVED 记录,所以这个 skill 目前只能联网、不能起子进程------这就是"声明了还要审批"在数据上的样子。

审批数据不用手工写 SQL:下载时 NacosSkillRepositoryHolder 解析 SKILL.md 声明的 permissions,自动为每个权限生成一条 PENDING 待审批记录(幂等,同一权限不重复生成);管理员通过 /v0/admin/skill-governance 批准或驳回;因为 evaluate() 每次都查库,批准后立即生效、无需重启。管理接口清单见 6.3。

4.5 审计留痕:skill_audit_log 与存储接口

审计记录由 SkillGovernanceService.recordAudit 写入,成功和失败路径都会记。skill_audit_log 表记录每次裁决结果和执行结果:拦截(EXECUTE_BLOCKED)、执行失败(EXECUTE_FAILED)、输出非法(OUTPUT_INVALID)、成功(EXECUTE_OK),带 blocked 标记区分是否被拦截。审计写入失败只打 WARN、不阻断业务,但必须能被观测到,否则留痕就断了。

bash 复制代码
CREATE TABLE IF NOT EXISTS skill_audit_log (
    id BIGSERIAL PRIMARY KEY,
    skill_name VARCHAR(128) NOT NULL,
    version VARCHAR(64) NOT NULL,
    event_type VARCHAR(64) NOT NULL,
    detail TEXT,
    blocked BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_skill_audit_log_skill ON skill_audit_log(skill_name, version);
CREATE INDEX IF NOT EXISTS idx_skill_audit_log_created ON skill_audit_log(created_at);

审计表的数据长这样,和 4.4 的例子接上(subprocess 没批 → 执行被拦;network 批了 → 执行成功):

id skill_name version event_type detail blocked created_at
1 skill-creator 1.2.0 EXECUTE_BLOCKED 权限未审批: subprocess true 2026-08-12 09:30:00
2 skill-creator 1.2.0 EXECUTE_OK exit=0 durationMs=1234 false 2026-08-12 10:25:00

审批和审计的数据访问都收敛在 SkillApprovalStore 接口上,方法按用途分三组:

  • 查询组:findApprovedPermissions 供执行时使用,findApprovalslistPendinglistByStatus 供管理端列表,findById 供批准前确认;
  • 写改组:createPending 幂等生成待审批,updateStatus 批准或驳回并记录审批人和时间;
  • 审计组:appendAudit 写留痕,listAudit 供管理端查看。

为什么用接口加 JDBC 实现而不是直接写死?因为服务层只需要依赖抽象,测试时换内存实现,生产用 JdbcSkillApprovalStore,行为不变。这也是 store 包存在的意义:

bash 复制代码
package com.yangtze.bankwarning.ai.store;

import java.util.List;
import java.util.Optional;

/**
 * Skill 治理数据存储(阶段三 · 审批与审计)。
 *
 * 职责:
 *   - 读取某 skill 某版本已审批通过的权限清单;
 *   - 生成/查询待审批记录,批准或驳回(产品化审批流);
 *   - 追加审计事件(发布/校验/执行/拦截等,留痕可追溯)。
 * 默认实现是 JdbcSkillApprovalStore(PostgreSQL),测试可换内存实现。
 */
public interface SkillApprovalStore {

    String STATUS_PENDING = "PENDING";
    String STATUS_APPROVED = "APPROVED";
    String STATUS_REJECTED = "REJECTED";

    /** 查询某 skill 某版本已审批通过的权限 */
    List<SkillApproval> findApprovedPermissions(String skillName, String version);

    /** 查询某 skill 某版本的全部审批记录(含待审批/已驳回) */
    List<SkillApproval> findApprovals(String skillName, String version);

    /** 查询全部待审批记录 */
    List<SkillApproval> listPending();

    /** 按状态查询审批记录,status 为 null 时返回全部 */
    List<SkillApproval> listByStatus(String status);

    /** 按 id 查询一条审批记录 */
    Optional<SkillApproval> findById(Long id);

    /** 幂等生成一条待审批记录(已存在则不动) */
    void createPending(String skillName, String version, String permission, String requestedBy);

    /** 更新审批状态(APPROVED/REJECTED),记录审批人与时间,返回是否更新成功 */
    boolean updateStatus(Long id, String status, String reviewer, String comment);

    /** 最近 N 条审计记录 */
    List<AuditRecord> listAudit(int limit);

    /** 追加一条审计事件 */
    void appendAudit(String skillName, String version, String eventType, String detail, boolean blocked);

    /** 一条审批记录 */
    record SkillApproval(Long id, String skillName, String version, String permission,
                         String status, String requestedBy, String reviewedBy, String comment) {
    }

    /** 一条审计记录 */
    record AuditRecord(String skillName, String version, String eventType, String detail,
                       boolean blocked, String createdAt) {
    }
}

4.6 多版本共存(③ 号闸):skill_versions

③ 号闸查的"版本状态"来自 skill_versions 表。这张表的存在,源于一个工程问题:本地缓存原来是单目录 .skills-cache/<skill>/,一个 skill 名只有一个当前版本------升级后旧版本被覆盖,想回滚、想查"上个月跑的是哪个版本"都无据可依。Nacos 管发布侧有哪些版本,但消费侧"哪个版本在用、能不能用"必须本地自己记。

它不存 skill 本体(内容在 Nacos/缓存),它管的是版本生命周期:哪些版本下载过、哪个是当前生效的、每个版本允不允许执行。一句话:内容是别人的,谁能跑它说了算。

目录结构:按版本分目录

下载/物化改为 .skills-cache/<skill>/<version>/,多个版本目录共存,执行入口只认当前生效版本;升级前就存在的旧布局 .skills-cache/<skill>/ 仍然兼容回退。

状态机:一个 skill 只有一个 ACTIVE

skill_versions 表记录每个已下载版本的状态:

状态 含义
ACTIVE 当前生效版本,执行、审批、治理都按它解析
RETIRED 历史版本,目录保留但不参与执行,可随时切回(回滚)
QUARANTINED 被隔离的版本,执行前裁决直接拒绝

下载新版本时自动注册并激活,旧 ACTIVE 自动降级 RETIRED;管理接口可以把任意已下载版本切回 ACTIVE。

建表与示例数据

版本表由 JdbcSkillVersionStore 启动时自举建表(CREATE TABLE IF NOT EXISTS):

bash 复制代码
CREATE TABLE IF NOT EXISTS skill_versions (
    id BIGSERIAL PRIMARY KEY,
    skill_name VARCHAR(128) NOT NULL,
    version VARCHAR(64) NOT NULL,
    source VARCHAR(32) NOT NULL DEFAULT 'nacos',
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    activated_at TIMESTAMP,
    updated_by VARCHAR(128),
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(skill_name, version)
);
-- 同一 skill 同一时刻只允许一个 ACTIVE(PostgreSQL 部分唯一索引)
CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_versions_active
    ON skill_versions(skill_name) WHERE status = 'ACTIVE';

数据长这样(skill-creator 下载过三个版本,1.0.0 出过事被隔离,2.0.0 当前生效):

id skill_name version source status activated_at updated_by
1 skill-creator 1.0.0 nacos QUARANTINED 2026-08-10 09:00:00 admin
2 skill-creator 1.2.0 nacos RETIRED 2026-08-12 10:23:00 nacos-sync
3 skill-creator 2.0.0 nacos ACTIVE 2026-08-14 15:40:00 nacos-sync

与审批表的分工

skill_approvals 管"某个版本的某个权限有没有被批准",skill_versions 管"这个版本在不在、是什么状态"------一个是权限粒度,一个是版本粒度。执行时 evaluate() 两个都查:版本状态不是 ACTIVE 直接拒绝,声明的权限缺审批记录也拒绝。

管理接口

/v0/admin/skill-versions(需 ADMIN / SUPER_ADMIN):

接口 作用
GET /skill-versions?skillName= 版本列表与状态
GET /skill-versions/{skillName}/active 当前生效版本
POST /skill-versions/{skillName}/{version}/activate 切换/回滚到某版本
POST /skill-versions/{skillName}/{version}/quarantine 隔离某版本(立即拒执行)
POST /skill-versions/{skillName}/{version}/unquarantine 解除隔离并激活
DELETE /skill-versions/{skillName}/{version} 删除记录与缓存目录

4.7 三个配置开关(①②④ 号闸):熔断、隔离、白名单

4.3 的地图里,①②④ 三张"牌"都来自 yml。三个开关覆盖三种应急/策略粒度,从全局到单个版本:

  • kill-switch:全局熔断,一键停用所有动态 skill(① 号闸);
  • quarantined-versions:隔离清单,命中即拒绝,应急下架某个版本(② 号闸的 yml 通道);
  • allowed-versions:skill@version 批准清单,配置了清单就只放行清单内版本(④ 号闸)。

allowed-versions 的定位要先澄清:它不是"每个 skill 的版本注册表",而是一个可选的收紧开关。默认空列表 = 不限制版本,日常的版本治理根本不经过它------哪个版本生效、回滚、下架,都走 4.6 的 skill_versions 表和它的管理接口。只有合规等场景要求"这批 skill 只允许跑这几个版本"时,运维才在 yml 里一次性锁死。真到了需要频繁动态维护白名单的时候,就是该把它迁进数据库的信号(见下文取舍)。

对应 application.yml 里 app.ai.skill.governance.* 的配置:

bash 复制代码
app:
  ai:
    skill:
      governance:
        enabled: true                    # 治理总开关
        kill-switch: false               # 全局熔断
        allowed-versions: []             # 版本批准清单,如 [pdf@1.0.0]
        quarantined-versions: []         # 隔离清单,如 [pdf@1.0.0]
        fail-on-unapproved-permission: true   # 权限未审批时拒绝;false=仅告警

不写这一段时吃代码里的默认值:kill-switch=false、两个清单为空,即"不熔断、不限制版本";需要启用就把对应项填进 yml(或环境变量覆盖)。

"隔离"有两条通道 :除了 yml 的 quarantined-versions 清单,还可以把 skill_versions 表里的版本状态置为 QUARANTINED(见 4.6),执行裁决时两条路径都会查。yml 通道给运维应急(不依赖数据库),表通道给管理端操作(持久化、可审计),是防御纵深而不是重复。

为什么版本清单和熔断留在 yml 而不是数据库:这是当前实现的一个取舍。审批流程和版本状态(skill_versions 表)已经产品化进库:权限逐项审批,版本支持多版本共存、下载自动激活、管理接口切换/隔离。但版本批准清单(allowed-versions)和全局熔断(kill-switch)仍走配置------白名单是常态化策略,熔断是要在数据库不可用时也能一键生效的应急开关。如果以后要支持管理端页面动态维护白名单,再把它迁到数据库。

4.8 evaluate() 收口:五道闸合成一个方法

SkillGovernanceService.evaluate 是执行前的最后一道闸,输入是 skill 名、版本、声明的权限,输出是 GovernanceDecision,包含是否放行、拒绝原因和警告。4.3 的五道闸在这里合成一个方法。

先看输出里的两个概念:reasonswarnings 是执行结果的分水岭------reasons 非空就拒绝执行,记 EXECUTE_BLOCKED;warnings 只在灰度模式下出现,表示权限没批但允许放行观察。fail-on-unapproved-permission=false 就是把 ⑤ 号闸这一项从拒绝切成告警,上线前先观察误报,稳定后再切回严格模式。

bash 复制代码
public GovernanceDecision evaluate(String skillName, String version, Set<String> requestedPermissions) {
    if (!enabled) {
        return GovernanceDecision.allow();
    }
    List<String> reasons = new ArrayList<>();
    List<String> warnings = new ArrayList<>();
    String key = skillName + "@" + version;

    if (killSwitch) {
        reasons.add("全局熔断(kill switch)已开启");
    }
    if (quarantinedVersions.contains(key)) {
        reasons.add("该版本已被隔离: " + key);
    }
    versionStore.findByVersion(skillName, version)
            .filter(v -> !"ACTIVE".equals(v.status()))
            .ifPresent(v -> reasons.add("版本状态不允许执行: " + key + " (" + v.status() + ")"));
    if (!allowedVersions.isEmpty() && !allowedVersions.contains(key)) {
        reasons.add("版本不在批准清单内: " + key);
    }

    Set<String> approved = store.findApprovedPermissions(skillName, version).stream()
            .map(SkillApprovalStore.SkillApproval::permission)
            .collect(Collectors.toSet());
    Set<String> missing = new LinkedHashSet<>();
    for (String permission : requestedPermissions == null ? Set.<String>of() : requestedPermissions) {
        if (!approved.contains(permission)) {
            missing.add(permission);
        }
    }
    if (!missing.isEmpty()) {
        String detail = "权限未审批: " + String.join(", ", missing);
        if (failOnUnapprovedPermission) {
            reasons.add(detail);
        } else {
            warnings.add(detail);
        }
    }
    return new GovernanceDecision(reasons.isEmpty(), reasons, warnings);
}

只要 enabled 打开,上述任一检查不通过都会带着原因拒绝执行。到这一步,治理闭环的四件事------权限审批、版本锁定、隔离与熔断、审计留痕------就都落在这一条执行链路上了。

五、接入:PdfService 的完整安全链路

前面分别讲了输出校验、审批治理和裁决逻辑,这一节把它们放进 PdfService,看一条完整的执行链路怎么串起来。五步对应代码里的五个注释:

  1. 读元数据。SkillMetadata.parse 从 skill 目录的 SKILL.md 解析出版本、权限和输出契约,后面两步都要用到它。
  2. 治理裁决。governance.evaluate 拿着版本和权限去查熔断、隔离、版本清单和审批记录,拒绝就记 EXECUTE_BLOCKED 并直接返回失败,脚本不会启动。
  3. 沙箱执行。只有裁决放行的脚本才会进 sandboxExecutor.execute,这是阶段二的隔离。
  4. 输出校验。沙箱跑完后,outputValidator.validate 按元数据里的 output 契约验收 stdout,非法就记 OUTPUT_INVALID 并拒绝,不把内容返回给调用方。
  5. 审计留痕。成功路径记 EXECUTE_OK,带上退出码和耗时;失败路径已经在前面各步分别记过。

每一步失败都会带着原因返回,agent 拿到的是可读的错误而不是半截结果。完整代码:

bash 复制代码
// 1. 读取 skill 元数据(版本 / 权限 / 输出契约)
SkillMetadata metadata = SkillMetadata.parse(skillDir);

// 2. 执行前治理裁决,拒绝则拦截并审计
GovernanceDecision decision = governance.evaluate(skillName, metadata.getVersion(), metadata.getPermissions());
if (!decision.isAllowed()) {
    String detail = String.join("; ", decision.reasons());
    governance.recordAudit(skillName, metadata.getVersion(), "EXECUTE_BLOCKED", detail, true);
    return Map.of("success", false, "error", "Skill 执行被治理策略拒绝: " + detail);
}

// 3. 沙箱执行(阶段二)
SkillSandboxExecutor.SandboxResult result = sandboxExecutor.execute(sandboxReq);

// 4. 输出契约校验,失败即拒绝(阶段三)
SkillOutputValidator.ValidationResult outputCheck = outputValidator.validate(metadata, result);
if (!outputCheck.isValid()) {
    governance.recordAudit(skillName, metadata.getVersion(), "OUTPUT_INVALID", outputCheck.getReason(), true);
    return Map.of("success", false, "error", "脚本输出未通过校验: " + outputCheck.getReason());
}

// 5. 审计留痕
governance.recordAudit(skillName, metadata.getVersion(), "EXECUTE_OK",
        "exit=" + result.getExitCode() + " durationMs=" + result.getDurationMs(), false);
return Map.of("success", true, "content", result.getStdout());

到这里,一个 skill 脚本从下载到结果返回,要穿过六道闸:完整性校验、路径守卫、静态扫描、治理裁决、沙箱执行、输出校验,每一道都有留痕。

六、复用指南

6.1 文件清单

文件 职责
SkillMetadata.java security 解析 SKILL.md frontmatter(name/version/output/permissions)
SkillOutputValidator.java security 按输出契约校验沙箱结果,fail-closed
SkillApprovalStore.java store 审批查询 + 审计写入的存储接口
JdbcSkillApprovalStore.java store 接口的 JDBC 实现,启动自举建表
SkillGovernanceService.java service 治理裁决入口(熔断/版本/隔离/审批)
SkillApprovalService.java service 审批服务:下载自动生成待审批、批准/驳回/列表
SkillCacheService.java service 缓存物化:启动物化 + 下载后立即刷新
SkillGovernanceController.java controller 管理端接口(ADMIN/SUPER_ADMIN):审批列表/批准/驳回/审计
SkillVersionStore.java store 版本记录与状态存储接口
JdbcSkillVersionStore.java store 版本存储 JDBC 实现,启动自举建表
SkillVersionService.java service 版本注册/激活/隔离/删除,解析当前生效目录
SkillVersionController.java controller 管理端版本接口(列表/激活/隔离/删除)

依赖关系:SkillGovernanceServiceSkillApprovalService 都依赖 SkillApprovalStoreSkillGovernanceService 额外依赖 SkillVersionStore(版本状态门禁);SkillVersionService 依赖 SkillVersionStoreSkillCacheServiceSkillCacheService 依赖 PythonImportScannerSkillOutputValidator 依赖 Jackson 的 ObjectMapper(Spring 容器里已有)。治理裁决、输出校验、沙箱执行都在 PdfService 的执行链路中注入调用。整理后的分层是:纯安全能力放 security 包,业务服务放 service 包,管理接口放 controller 包,审批/审计数据存储放 store 包。

6.2 接入点清单

  1. 建表:审批表(skill_approvals)、审计表(skill_audit_log)和版本表(skill_versions)的 DDL 见 4.4 / 4.5 / 4.6,或让 JdbcSkillApprovalStore / JdbcSkillVersionStore 启动自举;
  2. 配治理开关(配置见下方);
  3. 在 skill 脚本执行前调用 governance.evaluate,拒绝就拦截(含版本状态门禁);
  4. 在沙箱执行成功后调用 outputValidator.validate,失败就拒绝;
  5. 每个分支都调用 governance.recordAudit 留痕;
  6. 版本管理:下载自动注册并激活,管理接口 /v0/admin/skill-versions 切换/隔离/删除(前端页面可按需补)。

治理配置(application.yml):

bash 复制代码
app:
  ai:
    skill:
      governance:
        enabled: true                    # 治理总开关
        kill-switch: false               # 全局熔断
        allowed-versions: []             # 版本批准清单,如 [pdf@1.0.0]
        quarantined-versions: []         # 隔离清单,如 [pdf@1.0.0]
        fail-on-unapproved-permission: true   # 权限未审批时拒绝;false=仅告警

6.3 审批数据怎么进表

审批流已经产品化,不需要手工写 SQL:

  1. 下载自动生成NacosSkillRepositoryHolder 下载 skill 后解析 SKILL.md 声明的 permissions,自动为每个权限生成一条 PENDING 待审批记录(幂等,同一权限不重复生成);
  2. 管理接口操作/v0/admin/skill-governance,需 SUPER_ADMIN / ADMIN 角色):
接口 作用
GET /approvals?status=PENDING 待审批列表
GET /approvals?status=ALL 全部审批记录(含已驳回)
GET /approvals/skill/{skillName}?version= 某 skill 的审批记录
POST /approvals/{id}/approve 批准,记录操作人
POST /approvals/{id}/reject 驳回,可带意见
POST /approvals/{id}/resubmit 已驳回记录重新提交
GET /audit 审计日志
  1. 批准后立即生效:执行时 evaluate() 每次都查询数据库,状态变为 APPROVED 后无需重启即可执行;
  2. 下载后立即生效SkillVersionService.registerDownload 在下载完成后按版本物化到 .skills-cache/<skill>/<version>/ 并登记,脚本无需重启即可执行;
  3. 前端配套:SKILLS 面板显示每个 skill 的版本与审批徽标(已批准/待审批/已驳回/无需审批),管理端 Skill 审批页提供待审批/全部记录/审计日志三个 Tab,支持批准、驳回(带意见)、重新提交。
  4. 版本记录怎么进表 :下载时 SkillVersionService.registerDownload 自动注册并激活(旧 ACTIVE 降级 RETIRED);切换/隔离/删除走 /v0/admin/skill-versions 管理接口,无需手工 SQL。

七、知识点小结

  1. 沙箱防逃逸,输出校验防污染。阶段二解决脚本跑不出笼子,阶段三解决笼子里吐出来的结果是否可信,两者缺一不可。
  2. fail-closed 是治理的默认姿势。截断、非法 JSON、未审批权限,一律当失败处理,宁可错杀不可漏放。
  3. 权限是申请出来的,不是声明出来的SKILL.md 里的 permissions 只是申请,能不能用由审批记录决定,灰度期可以降级为告警。
  4. 应急三板斧:全局熔断、版本隔离、版本批准清单,覆盖全停、停某个版本、只放行白名单三种粒度。
  5. 审计失败不阻断业务,但必须告警。留痕是合规要求,不能让审计写入拖垮主流程,也不能静默丢失。
  6. 治理的信任边界要交代:输出校验不防格式合法但语义恶意的输出,那要靠下游业务校验;审批记录防不住审批者本人违规授权,那要靠独立审计。这两点不是阶段三的缺陷,而是它的边界。
  7. 版本是准入单位,不是存储单位 。Nacos 管发布侧有哪些版本,本地 skill_versions 管消费侧"哪个版本在用、能不能用";多版本共存让升级可回滚、状态可审计,隔离的版本执行前直接被裁决拦下。

三篇合起来,就是一条完整的消费侧防线:

bash 复制代码
下载 → 完整性校验 → 物化/路径守卫 → 静态扫描 → 治理裁决 → 沙箱执行 → 输出校验 → 审计
   └────────────── 阶段一(内容可信)──────────────┘  └──── 阶段二(执行受控)────┘  └─ 阶段三(结果可信+治理)─┘

本文来自一次真实的加固实践。印象最深的是两件事:一是截断输出从告警收紧成失败后,下游再也不会拿到半截数据;二是治理裁决的检查顺序必须固定,熔断、隔离、版本、审批逐层过滤,漏一层都可能让应急失效。三篇系列到此收官,从内容可信到执行受控,再到结果可信与全程可审计。

相关推荐
Poo_Chai1 小时前
QT emit信号后完整处理流程,包括槽函数响应流程
java·开发语言·数据库
瑞码空间1 小时前
Java 图形界面(GUI)完整知识点手册
java·开发语言·图形界面·swing
茶本无香1 小时前
Java调用Shell脚本执行SQL数据库操作:从入门到实战
java·sql·shell
风流 少年2 小时前
Spring AI 2.0:MCP
java·后端·spring
天疆说2 小时前
01 硬件选型与 llama.cpp 部署:4× RTX 5880 Ada 跑 DeepSeek-V4-Flash
java·redis·llama
水无痕simon3 小时前
3 短信应用场景以及平台架构
java·服务器
mister_guo3 小时前
JVM 内存管理原理及生产配置实战:从“参数能启动”到“内存可控”
java·jvm
风流 少年3 小时前
Spring AI 2.0:Tool
java·python·spring