一、背景:执行被关进笼子之后,还剩两个问题
前两篇把 skill 供应链的执行前和执行中两个环节都加固了:
- 阶段一保证内容可信:下载完整性校验(HMAC)、静态 import 扫描、路径防逃逸;
- 阶段二保证执行受控:进程级软沙箱 + 容器级硬沙箱,恶意脚本跑不出笼子。
但站在供应链的视角看,还差两块:
- 结果可信。沙箱只保证脚本跑不出笼子,不保证脚本吐出来的东西是对的。改造前,负责调用脚本的 PdfService 把脚本打印到标准输出(stdout,也就是脚本输出的文本)原样当成可信结果返回。脚本只要输出一段格式错误的 JSON,或者输出太大被截断(比如 JSON 被拦腰切断),调用方毫不知情,原样拿去做报告,下游就会解析出错误的结果。
- 治理闭环。谁批准了某个 skill 使用网络权限?哪个版本可以上线?出事了怎么一键停用?这些事前两篇没有管。没有审批和审计,这条链就缺了治理这一环。
阶段三回答这两个问题:输出契约校验 + 治理闭环。
二、整体设计
阶段三的防线落在执行链路的两端:
bash
下载(阶段一)→ 物化/扫描(阶段一)→ 治理裁决(阶段三)→ 沙箱执行(阶段二)→ 输出校验(阶段三)→ 审计(阶段三)
两块内容:
- 输出契约校验 :SKILL.md frontmatter 声明
output: text|json,执行后按契约校验,校验失败一律按执行失败处理(fail-closed),不把不可信输出透传给下游。 - 治理闭环:四件事,权限审批、版本锁定、隔离与熔断、审计留痕。执行前做裁决,执行后记审计,任何一环出问题都能追溯、能应急。
三、输出契约校验
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 就是解析这份元数据的类,代码分四段看:
FRONTMATTER正则负责切出 frontmatter 段落,即两个---之间的 YAML,没有 frontmatter 就返回空元数据;field读取单个标量字段,顺手做三件清理:strip()去掉首尾空白、去掉行内注释、去掉引号,保证拿到的是干净的值;parsePermissions兼容两种写法,行内数组permissions: [network, subprocess]和列表形式,统一转小写,保证和审批表里的权限名比较一致;- 构造器把四个字段归一化后存成不可变对象,对外只读。
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,里面带一个是否通过和可选的原因。
三条规则如下:
- 执行未成功,也就是非零退出或超时,直接判定失败。
- 输出被截断,也就是超过沙箱上限,直接判定失败。截断意味着只拿到了一半内容,半截数据比没有数据更危险,它可能是被拦腰切断的 JSON,透传下去会让下游解析出错误的结果。
- 声明 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)
用文字把这条链路过一遍:
- skill 从 Nacos 下载并按版本物化到本地缓存(.skills-cache///,新版本自动激活为 ACTIVE),skill 本体在这里(阶段一);
- SKILL.md 声明 permissions,这只是提出申请;
- 下载时按 SKILL.md 声明的权限自动生成 PENDING 待审批记录,管理员通过管理接口批准,状态变为 APPROVED 并记录操作人与时间;
- 执行时 evaluate() 每次查表核对:版本状态(skill_versions)、版本在不在允许清单、权限有没有审批记录,批准后立即生效,无需重启;
- 通过后沙箱执行、输出校验,最后写 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_by、reviewed_by、reviewed_at、comment记录申请人和审批人、时间、意见,审计时能还原谁在什么时候批了什么;UNIQUE(skill_name, version, permission)保证同一权限不会出现两行,这也是 createPending 幂等的前提。
光看字段还不够直观,拿真实数据举个例子。假设 skill-creator 在 SKILL.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供执行时使用,findApprovals、listPending、listByStatus供管理端列表,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 的五道闸在这里合成一个方法。
先看输出里的两个概念:reasons 和 warnings 是执行结果的分水岭------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,看一条完整的执行链路怎么串起来。五步对应代码里的五个注释:
- 读元数据。
SkillMetadata.parse从 skill 目录的 SKILL.md 解析出版本、权限和输出契约,后面两步都要用到它。 - 治理裁决。
governance.evaluate拿着版本和权限去查熔断、隔离、版本清单和审批记录,拒绝就记 EXECUTE_BLOCKED 并直接返回失败,脚本不会启动。 - 沙箱执行。只有裁决放行的脚本才会进
sandboxExecutor.execute,这是阶段二的隔离。 - 输出校验。沙箱跑完后,
outputValidator.validate按元数据里的 output 契约验收 stdout,非法就记 OUTPUT_INVALID 并拒绝,不把内容返回给调用方。 - 审计留痕。成功路径记 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 | 管理端版本接口(列表/激活/隔离/删除) |
依赖关系:SkillGovernanceService 与 SkillApprovalService 都依赖 SkillApprovalStore,SkillGovernanceService 额外依赖 SkillVersionStore(版本状态门禁);SkillVersionService 依赖 SkillVersionStore 与 SkillCacheService;SkillCacheService 依赖 PythonImportScanner;SkillOutputValidator 依赖 Jackson 的 ObjectMapper(Spring 容器里已有)。治理裁决、输出校验、沙箱执行都在 PdfService 的执行链路中注入调用。整理后的分层是:纯安全能力放 security 包,业务服务放 service 包,管理接口放 controller 包,审批/审计数据存储放 store 包。
6.2 接入点清单
- 建表:审批表(skill_approvals)、审计表(skill_audit_log)和版本表(skill_versions)的 DDL 见 4.4 / 4.5 / 4.6,或让
JdbcSkillApprovalStore/JdbcSkillVersionStore启动自举; - 配治理开关(配置见下方);
- 在 skill 脚本执行前调用
governance.evaluate,拒绝就拦截(含版本状态门禁); - 在沙箱执行成功后调用
outputValidator.validate,失败就拒绝; - 每个分支都调用
governance.recordAudit留痕; - 版本管理:下载自动注册并激活,管理接口
/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:
- 下载自动生成 :
NacosSkillRepositoryHolder下载 skill 后解析 SKILL.md 声明的 permissions,自动为每个权限生成一条 PENDING 待审批记录(幂等,同一权限不重复生成); - 管理接口操作 (
/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 | 审计日志 |
- 批准后立即生效:执行时 evaluate() 每次都查询数据库,状态变为 APPROVED 后无需重启即可执行;
- 下载后立即生效 :
SkillVersionService.registerDownload在下载完成后按版本物化到.skills-cache/<skill>/<version>/并登记,脚本无需重启即可执行; - 前端配套:SKILLS 面板显示每个 skill 的版本与审批徽标(已批准/待审批/已驳回/无需审批),管理端 Skill 审批页提供待审批/全部记录/审计日志三个 Tab,支持批准、驳回(带意见)、重新提交。
- 版本记录怎么进表 :下载时
SkillVersionService.registerDownload自动注册并激活(旧 ACTIVE 降级 RETIRED);切换/隔离/删除走/v0/admin/skill-versions管理接口,无需手工 SQL。
七、知识点小结
- 沙箱防逃逸,输出校验防污染。阶段二解决脚本跑不出笼子,阶段三解决笼子里吐出来的结果是否可信,两者缺一不可。
- fail-closed 是治理的默认姿势。截断、非法 JSON、未审批权限,一律当失败处理,宁可错杀不可漏放。
- 权限是申请出来的,不是声明出来的。SKILL.md 里的 permissions 只是申请,能不能用由审批记录决定,灰度期可以降级为告警。
- 应急三板斧:全局熔断、版本隔离、版本批准清单,覆盖全停、停某个版本、只放行白名单三种粒度。
- 审计失败不阻断业务,但必须告警。留痕是合规要求,不能让审计写入拖垮主流程,也不能静默丢失。
- 治理的信任边界要交代:输出校验不防格式合法但语义恶意的输出,那要靠下游业务校验;审批记录防不住审批者本人违规授权,那要靠独立审计。这两点不是阶段三的缺陷,而是它的边界。
- 版本是准入单位,不是存储单位 。Nacos 管发布侧有哪些版本,本地
skill_versions管消费侧"哪个版本在用、能不能用";多版本共存让升级可回滚、状态可审计,隔离的版本执行前直接被裁决拦下。
三篇合起来,就是一条完整的消费侧防线:
bash
下载 → 完整性校验 → 物化/路径守卫 → 静态扫描 → 治理裁决 → 沙箱执行 → 输出校验 → 审计
└────────────── 阶段一(内容可信)──────────────┘ └──── 阶段二(执行受控)────┘ └─ 阶段三(结果可信+治理)─┘
本文来自一次真实的加固实践。印象最深的是两件事:一是截断输出从告警收紧成失败后,下游再也不会拿到半截数据;二是治理裁决的检查顺序必须固定,熔断、隔离、版本、审批逐层过滤,漏一层都可能让应急失效。三篇系列到此收官,从内容可信到执行受控,再到结果可信与全程可审计。