Skip to content

Skill 安全凭证机制改造方案 —— 路径 B(密文 + sidecar 解密)

状态:方案设计,待执行 日期:2026-06-30 关联:skill-credentials.md(当前 egress 模式)、节点 101.96.214.49 部署记忆

1. 背景与问题

1.1 旧实现(egress 代理模式,已废弃移除)

⚠️ 该模式已废弃并从代码库移除:services/manager/app/api/skill_proxy.pyagent_skills.pyskill_credential_self_test 端点、router.pyCREDENTIAL_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_KEYcredential-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_configmetadata.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 envexecute_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 安全不需 keykey 在 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 组件

  1. manager save_skill_credentials:保存 DB 后调 controller write_skill_secrets,把密文 fan-out 到 Pod
  2. controller write_skill_secrets 端点:写 skills/{name}/secrets.enc(密文)到各 Pod home
  3. controller install_skill:install 时若该 skill 已有凭证,也写密文
  4. sidecar 镜像skill-secret-sidecar,新):python FastAPI,监听 :8004,读 secrets.enc + key 解密 + 返回指定参数明文
  5. controller _deploy_body:引擎 Pod spec 加 sidecar 容器(env credential_encryption_key,共享 /opt/data volume)
  6. 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 密文逻辑:

python
# 保存 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 方法:

python
@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/

dockerfile
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 行):

python
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 容器:

python
# 在 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 模板

markdown
## 使用 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 的 name

5.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 + 参数名返回)
不改 hermessidecar 是 Pod 内独立容器,不侵入 hermes

7. skill 代码用法

见 5.5。skill 开发者:

  1. manifest config_params 声明 secret 参数(name + secret:true)
  2. console 配置 secret 值
  3. skill 代码 execute_codelocalhost:8004/secret?skill=<name>&key=<参数名> 拿明文
  4. 用明文调外部 API

8. 部署与验证

部署步骤

  1. build sidecar 镜像(services/skill-secret-sidecar/
  2. build manager(含 5.1/5.2/5.4 改动)
  3. docker save | k3s ctr import 两个镜像
  4. kubectl rollout restart deploy manager -n unionagents(manager 改动)
  5. 重建引擎 Pod(让 _deploy_body 加 sidecar 生效):通过 manager API 触发引擎重建(suspend→resume 或 destroy→redeploy),或直接删 Pod 让 controller 重建
  6. 重装 credential-checker(触发 fan-out 写 secrets.enc)

验证

  • kubectl get pods 引擎 Pod 2/2 或 3/3(hermes + asr-sidecar + skill-secret-sidecar)Running
  • kubectl 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)

待确认

  1. credential_encryption_key 注入 sidecar:key 放 sidecar 容器 env。hermes 容器无此 env,execute_code 读不到 env(沙箱隔离)。接受?
  2. sidecar 共享 hermes-data PVC:sidecar 读 /opt/data/profiles/*/skills/。需确认 volumeMount 路径与 hermes 一致。
  3. 同 Pod 多 skill 互读:sidecar 按 skill_name 返回,同 Pod 内 execute_code 能传任意 skill_name 拿同 agent 其他 skill 的 secret。若需隔离,sidecar 加"调用方 skill"校验(但目前 execute_code 不带 skill 身份,难)。当前接受同 agent 可信域。
  4. 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. 相关代码

模块位置
凭证存储 APIservices/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-outservices/manager/app/worker/router.py(_fanout_skill_to_homes / _deploy_body)
前端配置入口apps/admin/src/views/agent-definitions/detail/SkillsTab.vue
示例 skillassets/skills/general/credential-checker/

基于内网部署的企业级 AI 智能体平台