ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【雕虫大技】Agent 动态 Skill 供应链安全加固(三):输出校验与治理闭环实战——用 TaoToken 统一 Key 打通校验链路

【雕虫大技】Agent 动态 Skill 供应链安全加固(三):输出校验与治理闭环实战——用 TaoToken 统一 Key 打通校验链路 1. 从一次“半截 JSON”事故说起Agent 动态 Skill 供应链安全加固这个系列前两篇分别解决了“内容可信”和“执行受控”下载时做 HMAC 完整性校验、静态 import 扫描、路径防逃逸执行时套进程级软沙箱加容器级硬沙箱。脚本跑不出笼子了但站在供应链视角看还差两块——结果可信和治理闭环。结果可信的问题很具体沙箱只保证脚本跑不出笼子不保证脚本吐出来的东西是对的。改造前负责调用脚本的 PdfService 把脚本打印到 stdout 的内容原样当成可信结果返回。脚本只要输出一段格式错误的 JSON或者输出太大被截断JSON 被拦腰切断调用方毫不知情原样拿去做报告下游就会解析出错误结果。治理闭环的问题同样直接谁批准了某个 skill 使用网络权限哪个版本可以上线出事了怎么一键停用没有审批和审计这条链就缺了治理这一环。这篇要交付的是输出契约校验 治理闭环的完整落地同时把多工具统一鉴权与调用通道这件事用 TaoToken 收口——因为校验链路本身也要调模型做语义复核如果每个工具各配一套 Key治理闭环反而多了一个鉴权黑洞。TaoToken 在这里的角色是统一 Key 与调用通道一个 Key 打通校验链路里所有需要模型调用的环节不改变现有 Skill 治理流程。适合谁看正在给 Agent 动态 Skill 做供应链加固、需要为多工具统一鉴权、并且希望输出校验和治理动作能闭环追溯的开发者。下面从整体设计讲到可复制的配置骨架再到连通性验证和排障。2. 整体设计防线落在执行链路两端阶段三的防线落在执行链路的两端下载阶段一→ 物化/扫描阶段一→ 治理裁决阶段三 → 沙箱执行阶段二→ 输出校验阶段三→ 审计阶段三两块内容。输出契约校验SKILL.md frontmatter 声明output: text|json执行后按契约校验校验失败一律按执行失败处理fail-closed不把不可信输出透传给下游。治理闭环权限审批、版本锁定、隔离与熔断、审计留痕四件事执行前做裁决执行后记审计任何一环出问题都能追溯、能应急。而校验链路里有一环需要模型参与——比如对 JSON 输出做语义合理性复核或者对文本输出做敏感信息二次判断。这一环如果每个 Skill 各配一套 API Key治理闭环就多了一个不受控的鉴权入口。所以我把这一环的调用统一走 TaoToken一个 Key、一个 base_url所有 Skill 的校验调用都从同一个通道出去审计日志里能对齐到同一个调用来源。3. TaoToken 前置统一 Key 与调用通道TaoToken 在这里解决的是“多工具统一鉴权”的问题。校验链路里可能同时有 Cline、CC Switch、以及自研的校验服务在调模型如果各自维护 Key轮换、审计、限流都是分散的。统一到一个 Key 之后治理侧只需要管一个凭证的生命周期。先拿 Key。访问控制台创建 API Key控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewriteAPI 基地址统一用https://taotoken.net/api注意API 地址不加 UTM 参数直接写死即可。模型对话入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。注意Key 只放在服务端环境变量或本地配置里不要提交进仓库也不要在 Skill 脚本里硬编码。校验链路的所有调用都从服务端出口走。4. 可复制配置settings.json / config.toml 骨架这一节给三份可直接抄的骨架Claude Code 的 settings.json、Cline 的 config、以及 CC Switch 的切换配置。核心思路是把 base_url 和 Key 收敛到一处Skill 校验调用只引用环境变量。4.1 Claude Code settings.json{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_AUTH_TOKEN: ${TAOTOKEN_API_KEY}, ANTHROPIC_MODEL: claude-sonnet-4-5 }, permissions: { allow: [ Bash(python3:*), Read(.skills-cache/**) ] } }ANTHROPIC_AUTH_TOKEN用环境变量占位实际值从 shell 注入。这样 Skill 校验脚本读到的永远是同一个出口。4.2 Cline configconfig.toml 风格[provider] name taotoken base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY model claude-sonnet-4-5 [skill_validation] enabled true fail_closed true max_output_bytes 262144 semantic_check truefail_closed true对应后文的 fail-closed 策略校验不通过就拒绝不放行。max_output_bytes是截断阈值超过即判定失败。4.3 CC Switch 配置示例CC Switch 用来在多个 provider 之间切换这里把 TaoToken 作为一个固定 profile{ profiles: [ { name: taotoken-main, base_url: https://taotoken.net/api, api_key_env: TAOTOKEN_API_KEY, models: { default: claude-sonnet-4-5, fast: claude-haiku-4-5 } } ], active: taotoken-main }三份配置的共同点base_url 只出现一次Key 只从环境变量读。Skill 校验链路无论从哪个工具发起出口都是同一个。5. 输出契约校验从声明到 fail-closed5.1 契约从哪来契约放在 SKILL.md 的 frontmatter 里因为它是 skill 自带的元数据作者上传时就要写清楚--- name: pdf version: 1.0.0 output: json permissions: [network] ---四个字段各有用途name用于展示和归属version用于审批和版本锁定按skillversion匹配output是输出契约text表示纯文本json表示 JSONpermissions是申请的权限逐项走审批。缺省值遵循最小权限、最保守兜底版本缺失按0.0.0审批记录也按0.0.0记不会因为版本缺失绕过审批output缺失按text没有声明 json 契约就不做 JSON 强校验permissions缺失按空集不申请权限就不需要审批。5.2 校验规则SkillOutputValidator是验收员输入是元数据和沙箱执行结果输出是ValidationResult。三条规则执行未成功非零退出或超时直接判定失败输出被截断超过沙箱上限直接判定失败——截断意味着只拿到一半内容半截数据比没有数据更危险它可能是被拦腰切断的 JSON声明 json 契约时输出必须非空且能被严格解析readTree解析失败包括输出尾部混着其他字符都判定失败。public ValidationResult validate(SkillMetadata metadata, SkillSandboxExecutor.SandboxResult result) { if (result null || !result.isSuccess()) { return ValidationResult.invalid(脚本执行未成功无法校验输出); } 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) { return ValidationResult.invalid(输出不是合法 JSON: e.getMessage()); } } return ValidationResult.valid(); }为什么一律 fail-closed因为这条链路的下游是报告和业务判断宁可拒绝一次边缘情况也不能让一份格式非法的输出混进结果。Validator 只负责格式对不对语义对不对由下游业务校验这是它的信任边界。5.3 接入位置PdfService 在沙箱执行成功之后、返回内容之前调用校验失败就拒绝并记审计。位置选在这一刻是为了让校验发生在数据离开可信边界的最后一环既能看到完整输出又能挡住它进入下游。原来的“截断只告警”行为在这里被收紧截断即失败。6. 治理闭环五道闸与三张表6.1 裁决五道闸治理闭环只回答一个问题一个从 Nacos 下载来的skillversion要执行凭什么放行答案是执行前过一道裁决evaluate()把两处数据合并成放行或拒绝执行后写审计。skillversion 请求执行 │ ▼ ┌───────────── evaluate() 裁决 ─────────────┐ │ ① 全局熔断 kill-switch ← yml │ │ ② 隔离 quarantined-versions ← yml 库 │ │ ③ 版本状态 skill_versionsACTIVE ← 数据库 │ │ ④ 版本白名单 allowed-versions ← yml │ │ ⑤ 权限审批 skill_approvals ← 数据库 │ │ 任一不过 → 拒绝并记审计 │ └────────────────────────────────────────────┘ │ 全部通过 ▼ 沙箱执行 → 输出校验 → 审计留痕skill_audit_log顺序必须固定越靠前的检查越紧急熔断、隔离和版本状态是应急动作必须最先生效版本白名单和权限是常态化策略排在后面。顺序乱了可能出现“版本不在白名单但权限已批准”这种先放后拦的错位。6.2 数据分工数据库管审计yml 管应急分工原则只有一条数据库管需要持久化、要审计、要被管理端改的数据yml 管运维策略和应急开关。它们必须做到“数据库不可用时也能生效”——应急开关如果依赖数据库数据库故障时反而失灵。数据存在哪谁写skill 本体Nacos / 本地缓存上传、下载物化权限审批记录skill_approvals 表下载自动生成管理接口批准版本记录与状态skill_versions 表下载自动注册管理接口切换批准清单 / 熔断application.yml运维修改配置审计记录skill_audit_log 表程序自动写入6.3 权限审批表权限不是声明了就能用。SKILL.md 声明permissions只是提出申请能不能用取决于skill_approvals表里有没有审批记录。粒度上最容易误解审批表里一行是一个权限不是整个 skillskill 声明了几个权限表里就有几行管理员可以只批其中一部分。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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(skill_name, version, permission) );status是审批状态机PENDING 待审批APPROVED 通过REJECTED 驳回重新提交后回到 PENDING。UNIQUE(skill_name, version, permission)保证同一权限不会出现两行也是createPending幂等的前提。假设 skill-creator 声明了permissions: [network, subprocess]下载后表里生成两行network 已批准subprocess 还是 PENDING。执行时evaluate()逐行比对subprocess 没有 APPROVED 记录所以这个 skill 目前只能联网、不能起子进程——这就是“声明了还要审批”在数据上的样子。6.4 审计表审计记录由SkillGovernanceService.recordAudit写入成功和失败路径都会记。事件类型包括拦截EXECUTE_BLOCKED、执行失败EXECUTE_FAILED、输出非法OUTPUT_INVALID、成功EXECUTE_OK带blocked标记区分是否被拦截。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);审计写入失败只打 WARN、不阻断业务但必须能被观测到否则留痕就断了。6.5 版本表与多版本共存本地缓存原来是单目录.skills-cache/skill/一个 skill 名只有一个当前版本升级后旧版本被覆盖想回滚、想查“上个月跑的是哪个版本”都无据可依。改成按版本分目录.skills-cache/skill/version/多个版本目录共存执行入口只认当前生效版本。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) ); CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_versions_active ON skill_versions(skill_name) WHERE status ACTIVE;状态机ACTIVE 是当前生效版本RETIRED 是历史版本目录保留但不参与执行可随时切回QUARANTINED 是被隔离版本执行前裁决直接拒绝。下载新版本时自动注册并激活旧 ACTIVE 自动降级 RETIRED。6.6 evaluate() 收口五道闸在SkillGovernanceService.evaluate里合成一个方法输出GovernanceDecision包含是否放行、拒绝原因和警告public GovernanceDecision evaluate(String skillName, String version, SetString requestedPermissions) { if (!enabled) { return GovernanceDecision.allow(); } ListString reasons new ArrayList(); ListString 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)); if (!allowedVersions.isEmpty() !allowedVersions.contains(key)) { reasons.add(版本不在批准清单内: key); } SetString approved store.findApprovedPermissions(skillName, version) .stream().map(SkillApproval::permission).collect(Collectors.toSet()); SetString missing new LinkedHashSet(); for (String p : requestedPermissions null ? Set.Stringof() : requestedPermissions) { if (!approved.contains(p)) { missing.add(p); } } if (!missing.isEmpty()) { String detail 权限未审批: String.join(, , missing); if (failOnUnapprovedPermission) { reasons.add(detail); } else { warnings.add(detail); } } return new GovernanceDecision(reasons.isEmpty(), reasons, warnings); }reasons非空就拒绝执行记EXECUTE_BLOCKEDwarnings只在灰度模式下出现表示权限没批但允许放行观察。fail-on-unapproved-permissionfalse就是把 ⑤ 号闸从拒绝切成告警上线前先观察误报稳定后再切回严格模式。7. 验证请求校验链路连通性配置写完先验证链路通不通再谈治理。分三步验证 Key 可用、验证输出契约校验生效、验证治理裁决拦截生效。7.1 验证 Key 与调用通道用 curl 打一次模型对话接口确认 base_url 和 Key 都对export TAOTOKEN_API_KEYsk-你的key curl -sS https://taotoken.net/api/v1/messages \ -H Authorization: Bearer ${TAOTOKEN_API_KEY} \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-5, max_tokens: 64, messages: [{role: user, content: reply with ok}] }返回里能看到正常的 message 结构就说明通道通了。如果返回 401检查 Key 是否带上了Bearer前缀返回 404检查 base_url 是否误加了路径后缀。7.2 验证输出契约校验构造一个故意输出非法 JSON 的 skill跑一次确认被拦# 模拟沙箱输出尾部带多余字符不是干净 JSON echo {result: 1} trailing-garbage /tmp/bad_output.json python3 - PY import json raw open(/tmp/bad_output.json).read().strip() try: json.loads(raw) print(PASS: 合法 JSON) except json.JSONDecodeError as e: print(fBLOCKED: 输出不是合法 JSON - {e}) PY预期输出BLOCKED。这一步对应SkillOutputValidator里readTree失败的分支说明 fail-closed 生效。7.3 验证治理裁决把某个版本置为 QUARANTINED再触发一次执行确认被拦并留下审计curl -sS -X POST \ https://your-host/v0/admin/skill-versions/pdf/1.0.0/quarantine \ -H Authorization: Bearer ${ADMIN_TOKEN} # 触发执行后查审计 curl -sS https://your-host/v0/admin/skill-governance/audit?limit5 \ -H Authorization: Bearer ${ADMIN_TOKEN}审计里应出现EXECUTE_BLOCKEDdetail 为“该版本已被隔离: pdf1.0.0”。到这一步输出校验和治理裁决两条链路都验证过了。8. 本篇常见错排查报错一输出不是合法 JSON: Unexpected character。多半是脚本在 JSON 后面打印了日志或进度条。检查脚本是否把调试信息写到了 stdout应该改到 stderr。校验器只认 stdout 是干净 JSON。报错二脚本输出超过上限被截断。输出体积超过max_output_bytes。要么调大阈值要么让脚本分页输出。注意截断即失败是刻意设计不要为了“能跑通”把截断降级成告警。报错三权限未审批: subprocess。这是治理裁决正常拦截不是 bug。去审批接口批准对应权限或确认该 skill 是否真的需要这个权限。批准后evaluate()每次查库立即生效、无需重启。报错四版本状态不允许执行: pdf1.0.0 (RETIRED)。当前生效版本已经切到新版本旧版本是 RETIRED。要回滚就调 activate 接口把旧版本切回 ACTIVE。报错五401 / 鉴权失败。检查TAOTOKEN_API_KEY是否注入到运行环境以及 base_url 是否写成https://taotoken.net/api不要带多余路径。多工具场景下确认每个工具读的是同一个环境变量而不是各自硬编码了旧 Key。报错六审计表查不到记录。审计写入失败只打 WARN 不阻断业务所以业务成功不代表审计成功。检查数据库连接和表是否已自举创建日志里搜[skill-audit]关键字。9. 接入清单与后续文件分层纯安全能力放security包SkillMetadata、SkillOutputValidator业务服务放service包SkillGovernanceService、SkillApprovalService、SkillVersionService管理接口放controller包审批/审计/版本数据存储放store包。接入点清单建三张表审批、审计、版本或让 JDBC 实现启动自举配治理开关在 skill 脚本执行前调governance.evaluate拒绝就拦截在沙箱执行成功后调outputValidator.validate失败就拒绝每个分支都调recordAudit留痕版本管理走/v0/admin/skill-versions接口。治理配置骨架app: ai: skill: governance: enabled: true kill-switch: false allowed-versions: [] quarantined-versions: [] fail-on-unapproved-permission: true审批数据不用手工写 SQL下载时解析 SKILL.md 声明的 permissions自动为每个权限生成 PENDING 记录幂等管理员通过/v0/admin/skill-governance批准或驳回批准后立即生效。如果你还在把校验链路的模型调用分散在各个工具里建议先把 Key 收敛到 TaoToken 一处再谈治理闭环——否则审计日志里会出现多个来源追溯时对不齐。长期跑编码和 Agent 任务的话可以看下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。Claude Code 接入细节在 https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-codeutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。最后留一个我踩过的坑治理裁决的检查顺序必须固定熔断、隔离、版本、审批逐层过滤漏一层都可能让应急失效。我一开始把版本白名单放在版本状态前面结果隔离动作被白名单短路隔离版本照样能跑。顺序调回来之后应急三板斧才真正生效。
返回列表