Skill 安全凭证机制改造方案 —— 路径 B(密文 + sidecar 解密)
状态:方案设计,待执行 日期:2026-06-30 关联:skill-credentials.md(当前 egress 模式)、节点 101.96.214.49 部署记忆
1. 背景与问题
1.1 旧实现(egress 代理模式,已废弃移除)
⚠️ 该模式已废弃并从代码库移除:
services/manager/app/api/skill_proxy.py、agent_skills.py的skill_credential_self_test端点、router.py的CREDENTIAL_PROXY_URL注入与 env.json 写入均已删除。以下仅作历史背景,当前实现见第 4 节路径 B(sidecar 解密)。
- secret 经 console 配置 → Fernet 加密存
skill_credentials.credentials_encrypted(DB) - skill 调外部 API 时走 manager 出口代理(egress,
/api/engine-proxy/*) - egress 代理解密凭证 → 注入
Authorization头 → 转发外部 API - secret 明文不进 Pod(只在 manager 内存短暂解密)
1.2 hermes 引擎的限制(实测确认)
在节点 101.96.214.49 的 engine-hermes Pod 上验证:
| 项 | 结论 | 证据 |
|---|---|---|
| hermes 工具能发 HTTP 的 | 只有 execute_code(无 terminal/web_fetch/http 工具) | agent 自述工具列表 |
execute_code 沙箱读 Pod env | ❌ 隔离(读不到 CREDENTIAL_PROXY_URL/AGENT_ID/API_SERVER_KEY) | credential-checker v1.1.0 实测 os.environ.get 返回 None |
execute_code 沙箱读 Pod 文件 | ✅ 不隔离(能 open() + listdir /opt/data/profiles/.../skills/) | user console 实测 open SKILL.md/env.json 成功 |
execute_code 沙箱网络 | ✅ 可达 manager:8002(返回 404,服务在跑) | user console 实测 |
| hermes 把 config 注入 agent 上下文 | _inject_skill_config 把 metadata.hermes.config 值注入 LLM 上下文(message parts) | skill_commands.py:206 |
| hermes 是否把 Pod env 注入 agent 上下文 | ❌ 不注入业务 env(只 HERMES_* 内部变量) | grep /opt/hermes 源码 |
1.3 egress 模式在 hermes 上的死结
egress(skill_proxy.py _verify_engine_caller)要求 Authorization: Bearer <API_SERVER_KEY> 鉴权防越权。但:
API_SERVER_KEY在 Pod env →execute_code沙箱读不到API_SERVER_KEY是 secret → 不能写文件(env.json,安全策略拒绝 secret 落盘)- hermes 引擎层不代调 egress(不认识)
→ hermes 上 skill 代码无法安全直接调 egress(要调就得拿 API_SERVER_KEY,那是 secret 泄露)。当前验证改走 manager self-test 端点(manager 代调 egress),但那不是 skill 运行时使用。
1.4 新需求
用户要求改造为:skill 本地持有明文凭证直接调外部 API(不经 egress),保留 config_params 声明 secret 参数,secret 加密存储,skill 引用参数时自动解密,不调服务。
1.5 key 死结(核心障碍)
"secret 密文存 Pod 文件 + skill 自己解密"要求 skill 拿到 credential_encryption_key:
| key 通道 | 可行 | 原因 |
|---|---|---|
| Pod env | ❌ | execute_code 沙箱隔离 env |
| env.json 文件 | ❌ | key 是 secret,写文件泄露(安全策略拒绝) |
| hermes 引擎层解密 | ❌ | hermes 开源不改,不认识 secret |
| 不调服务 | ❌ | 不能调 manager 解密 |
→ 密文 + skill 自解密 + 不调服务三者不可同时满足。
2. 方案目标
- 保留
config_params声明 secret 参数(console 配置入口不变) - secret 加密存 DB(
skill_credentials,已有) - skill 运行时能拿到 secret 明文调外部 API(不经 egress)
- secret 明文不进 LLM 上下文(不在对话/日志泄露)
- secret 不落 Pod 明文(PVC/MinIO 不带明文)
- 不依赖 hermes 改造(开源不侵入)
3. 方案选型
三条让 skill 拿明文的路对比:
| 维度 | 路径 A(明文落 Pod) | 路径 B(密文+sidecar) | egress+sidecar(旧方案A) |
|---|---|---|---|
| Pod 文件存 | 明文 secret | 密文 | 不落 |
| PVC/MinIO 存档 | 带明文 ❌ | 带密文 ✅ | 不带 ✅ |
| skill 调服务 | 不调 ✅ | 调 sidecar(Pod内localhost) | 调 sidecar→egress |
| skill 互读越权 | 有(需隔离) | 无(sidecar 按 skill 校验) | 无 |
| key 安全 | 不需 key | key 在 sidecar ✅ | 不需 key(egress 解密) |
| skill 持明文 | 持 | 持 | 不持 |
| 复杂度 | 低 | 中 | 中 |
| 符合"本地使用" | ✅ | ✅ | ❌(egress 代调) |
选路径 B:密文落 Pod(安全于 A)+ sidecar 本地解密(Pod 内 localhost,非外部服务)+ skill 持明文本地调外部 API。
4. 路径 B 详细设计
4.1 架构
console 配置 secret → manager 加密存 DB(skill_credentials.credentials_encrypted)
│ save_skill_credentials 后触发 fan-out
▼
controller write_skill_secrets → 写密文 secrets.enc 到 Pod skills/{name}/
│
引擎 Pod ┌──────────────┴───────────────────┐
│ hermes 容器(execute_code) │ sidecar 容器(skill-secret-sidecar)
│ │ │ env: credential_encryption_key
│ │ execute_code 调 localhost:8004 │ 监听 :8004
│ ▼ │ ▲
│ GET localhost:8004/secret? │ │ 读 skills/{name}/secrets.enc
│ skill=xxx&key=api_key │ │ + credential_encryption_key 解密
│ │ │ │ 返回 {"value": "<明文 api_key>"}
│ ▼ 拿到明文 api_key │
│ execute_code 直接调外部 API │
│ (自己带 Authorization 头) │
└─────────────────────────────────────┘4.2 组件
- manager
save_skill_credentials:保存 DB 后调 controllerwrite_skill_secrets,把密文 fan-out 到 Pod - controller
write_skill_secrets端点:写skills/{name}/secrets.enc(密文)到各 Pod home - controller
install_skill:install 时若该 skill 已有凭证,也写密文 - sidecar 镜像(
skill-secret-sidecar,新):python FastAPI,监听 :8004,读secrets.enc+ key 解密 + 返回指定参数明文 - controller
_deploy_body:引擎 Pod spec 加 sidecar 容器(envcredential_encryption_key,共享/opt/datavolume) - SKILL.md 模板:execute_code 调 sidecar 拿明文 → 调外部 API
4.3 流程
1. console 配置 secret(api_key=xxx)
2. manager save_skill_credentials:Fernet 加密存 DB
3. manager 调 controller write_skill_secrets(agent_id, skill_name, credentials_encrypted)
4. controller 写密文到 Pod 各 home:{home}/skills/{skill_name}/secrets.enc
5. skill 运行时(user console 对话触发):
a. agent 用 execute_code 执行脚本
b. 脚本 GET localhost:8004/secret?skill=credential-checker&key=api_key
c. sidecar 读 secrets.enc + credential_encryption_key 解密 → 返回 {"value":"xxx"}
d. 脚本用 xxx 调外部 API(带 Authorization: Bearer xxx)5. 改动点(详细)
5.1 services/manager/app/api/agent_skills.py
save_skill_credentials 保存 DB 后,增加 fan-out 密文逻辑:
# 保存 DB 后,fan-out 密文到各实例 Pod
instance_ids = await _definition_instance_ids(db, definition_id)
for iid in instance_ids:
try:
await controller_client.write_skill_secrets(iid, skill_name, row.credentials_encrypted)
except controller_client.ControllerError as e:
logger.warning("fan-out skill secrets to %s failed: %s", iid[:8], e)5.2 services/manager/app/worker/router.py
加 write_skill_secrets 端点 + client 方法:
@router.post("/api/controller/agents/{agent_id}/skills/{skill_name}/secrets")
async def write_skill_secrets(agent_id: str, skill_name: str, body: dict, db=Depends(get_manager_db)):
"""把加密的 secret 写到各 Pod home 的 skills/{name}/secrets.enc(密文落盘)。"""
credentials_encrypted = body["credentials_encrypted"]
pods = await _iter_agent_target_pods(agent_id, db)
for p in pods:
for home in p["homes"]:
dest = f"{home}/skills/{skill_name}/secrets.enc"
await k8s_manager.exec_write_file_in_pod(p["pod_name"], dest, credentials_encrypted)
return {"ok": True}install_skill / _fanout_skill_to_homes 时若已有凭证,也写 secrets.enc(从 DB 取密文)。
5.3 sidecar 镜像(新)
services/skill-secret-sidecar/:
FROM python:3.11-slim
RUN pip install fastapi uvicorn cryptography
WORKDIR /app
COPY sidecar.py .
CMD ["uvicorn", "sidecar:app", "--host", "0.0.0.0", "--port", "8004"]sidecar.py(~50 行):
import os, json, glob
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse
from cryptography.fernet import Fernet, InvalidToken
import base64, hashlib
app = FastAPI()
KEY = base64.urlsafe_b64encode(hashlib.sha256(os.environ["CREDENTIAL_ENCRYPTION_KEY"].encode()).digest())
fernet = Fernet(KEY)
# sidecar 与 hermes 共享 /opt/data volume;home 由调用方或环境指定
DATA_ROOT = os.environ.get("UA_DATA_ROOT", "/opt/data/profiles")
def _decrypt_file(path: str) -> dict:
with open(path, "rb") as f:
token = f.read()
try:
return json.loads(fernet.decrypt(token))
except InvalidToken as e:
raise HTTPException(500, "decrypt failed")
@app.get("/secret")
async def get_secret(skill: str = Query(...), key: str = Query(...), home: str = Query(None)):
# 找该 skill 的 secrets.enc(base home 或指定 home)
candidates = glob.glob(f"{DATA_ROOT}/*/skills/{skill}/secrets.enc") if not home else [f"{home}/skills/{skill}/secrets.enc"]
if not candidates:
raise HTTPException(404, f"no secrets for skill {skill}")
creds = _decrypt_file(candidates[0])
if key not in creds:
raise HTTPException(404, f"secret {key} not configured")
return JSONResponse({"value": creds[key]})5.4 services/manager/app/worker/router.py _deploy_body
引擎 Pod spec 加 sidecar 容器:
# 在 Pod spec containers 加 sidecar
containers.append({
"name": "skill-secret-sidecar",
"image": "unionagents/skill-secret-sidecar:latest",
"ports": [{"containerPort": 8004}],
"env": [
{"name": "CREDENTIAL_ENCRYPTION_KEY", "value": settings.credential_encryption_key or "<dev派生>"},
{"name": "UA_DATA_ROOT", "value": "/opt/data/profiles"},
],
"volumeMounts": [{"name": "hermes-data", "mountPath": "/opt/data"}], # 共享 hermes 数据卷
"resources": {"requests": {"cpu": "50m", "memory": "64Mi"}, "limits": {"cpu": "200m", "memory": "128Mi"}},
})注意:sidecar 共享 hermes-data PVC(读 secrets.enc),但 env 注入 CREDENTIAL_ENCRYPTION_KEY(hermes 容器无此 env)。
5.5 SKILL.md 模板
## 使用 secret 参数
本技能的 secret 参数(manifest 声明 secret:true)已由平台加密存到 Pod。
用 execute_code 调本地 sidecar 获取解密后的明文(不经 egress,不读 env):
\```python
import urllib.request, json
# 从 sidecar 拿解密后的 secret
r = urllib.request.urlopen(
"http://localhost:8004/secret?skill=<本技能name>&key=<secret参数名>", timeout=5)
api_key = json.loads(r.read())["value"]
# 直接调外部 API(自己带凭证)
req = urllib.request.Request("https://api.example.com/data",
headers={"Authorization": f"Bearer {api_key}"})
print(urllib.request.urlopen(req).read().decode())
\```
注意:
- 不要 print secret 明文(避免进对话日志)
- secret 参数名取自 manifest config_params 的 name5.6 部署
- build sidecar 镜像 + manager(含 5.1/5.2/5.4 改动)
docker save | k3s ctr import+kubectl rollout restart- 重装 credential-checker(触发 fan-out 写 secrets.enc)
- 验证
6. 安全分析
| 项 | 情况 |
|---|---|
| secret 落 Pod | 密文(secrets.enc),PVC/MinIO 带密文 |
| credential_encryption_key | 只在 sidecar 容器 env(Pod env),hermes 容器无此 env,execute_code 读不到 env |
| secret 明文去向 | sidecar→execute_code 进程内,不进 LLM 上下文(execute_code 不 print) |
| 越权防护 | Pod = 一个 agent instance,sidecar 只读本 Pod secrets.enc(k8s Pod 隔离);同 Pod 多 skill 互读属同 agent 可信域 |
| config_params 声明 | 保留(sidecar 按 skill_name + 参数名返回) |
| 不改 hermes | sidecar 是 Pod 内独立容器,不侵入 hermes |
7. skill 代码用法
见 5.5。skill 开发者:
- manifest
config_params声明 secret 参数(name + secret:true) - console 配置 secret 值
- skill 代码
execute_code调localhost:8004/secret?skill=<name>&key=<参数名>拿明文 - 用明文调外部 API
8. 部署与验证
部署步骤
- build sidecar 镜像(
services/skill-secret-sidecar/) - build manager(含 5.1/5.2/5.4 改动)
docker save | k3s ctr import两个镜像kubectl rollout restart deploy manager -n unionagents(manager 改动)- 重建引擎 Pod(让 _deploy_body 加 sidecar 生效):通过 manager API 触发引擎重建(suspend→resume 或 destroy→redeploy),或直接删 Pod 让 controller 重建
- 重装 credential-checker(触发 fan-out 写 secrets.enc)
验证
kubectl get pods引擎 Pod 2/2 或 3/3(hermes + asr-sidecar + skill-secret-sidecar)Runningkubectl exec <pod> -c skill-secret-sidecar -- env | grep CREDENTIAL_ENCRYPTION_KEY有值kubectl exec <pod> -- ls /opt/data/profiles/*/skills/credential-checker/secrets.enc存在- user console execute_code 调
localhost:8004/secret?skill=credential-checker&key=api_key返回明文 - execute_code 用明文调 httpbin,回显 Authorization 注入
9. 风险与待确认
风险
- 改
_deploy_body加 sidecar 影响引擎 Pod 部署,需验证 Pod 正常起 + sidecar 健康 - sidecar 容器资源开销(小,~64Mi)
- 凭证保存后 fan-out 密文到 Pod(save_skill_credentials 加 fan-out,凭证保存延迟略增)
- ssh 限流导致部署慢(节点 fail2ban)
- 重建引擎 Pod 才能让 sidecar 生效(_deploy_body 改动需新 Pod)
待确认
- credential_encryption_key 注入 sidecar:key 放 sidecar 容器 env。hermes 容器无此 env,execute_code 读不到 env(沙箱隔离)。接受?
- sidecar 共享 hermes-data PVC:sidecar 读
/opt/data/profiles/*/skills/。需确认 volumeMount 路径与 hermes 一致。 - 同 Pod 多 skill 互读:sidecar 按 skill_name 返回,同 Pod 内 execute_code 能传任意 skill_name 拿同 agent 其他 skill 的 secret。若需隔离,sidecar 加"调用方 skill"校验(但目前 execute_code 不带 skill 身份,难)。当前接受同 agent 可信域。
- home 路径:sidecar 用 glob 找
secrets.enc,或 execute_code 传 home。多 profile 时需确认路径。
10. 附录:验证结论(hermes 限制,方案依据)
- hermes 工具:
browser_*, cronjob, delegate_task, execute_code, image_generate, memory, patch, read_file, search_files, session_search, skill_manage, skill_view, skills_list, todo, vision_analyze, write_file(无 terminal/web_fetch/http) - execute_code 沙箱:env 隔离、文件可 open、网络可达 manager:8002
- hermes
_inject_skill_config(skill_commands.py:206):把 config 值注入 LLM 上下文(不适合 secret) - egress
skill_proxy.py_verify_engine_caller:要求Authorization: Bearer <API_SERVER_KEY> - 当前 manager self-test 端点(
agent_skills.py):manager 代调 egress 验证注入(已通,authorization_injected=true)
11. 相关代码
| 模块 | 位置 |
|---|---|
| 凭证存储 API | services/manager/app/api/agent_skills.py(save_skill_credentials / self-test) |
| 出口代理(旧) | services/manager/app/api/skill_proxy.py |
| 加解密 | services/manager/app/core/crypto.py |
| controller fan-out | services/manager/app/worker/router.py(_fanout_skill_to_homes / _deploy_body) |
| 前端配置入口 | apps/admin/src/views/agent-definitions/detail/SkillsTab.vue |
| 示例 skill | assets/skills/general/credential-checker/ |