ARTICLE DETAIL

资讯详情

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

Agent Skill中Python脚本鉴权:HMAC签名与时间戳防重放实战

Agent Skill中Python脚本鉴权:HMAC签名与时间戳防重放实战 最近在折腾 Agent Skill 插件开发把一堆业务逻辑封装成 Python 脚本交给大模型调度时碰到一个绕不开的问题Skill 里的 Python 代码到底怎么做鉴权处理直接说结论Skill 包里的 Python 脚本是在本地以子进程方式被拉起来执行的框架层面的用户登录态、API Token 根本到不了这一层。如果你不在脚本自己身上做鉴权谁拿到这个 Skill 目录谁就能直接跑你的代码甚至绕过宿主校验往脚本里塞伪造参数。所以鉴权必须在脚本内部单独做一层而且不能用传统的用户名密码方案——子进程是非交互的没有输入账号密码的场景。这篇文章会把 Skill 里 Python 鉴权的完整思路和可复现代码写出来。核心就是四个组件环境变量注入身份、HMAC-SHA256 签名、时间戳防重放、密钥文件权限锁。我自己在 Claude Code Skill、Cursor Skill 这类场景里都验证过这套逻辑也踩过不少坑适合正在开发 Agent Skill、插件脚本或者本地自动化工具的同学直接抄作业。1. Skill 里 Python 脚本的运行边界决定了鉴权方式1.1 Skill 的本质一个目录 一份描述文件 一堆脚本先统一一下认知。现在主流 Agent 工具都在推 Skill 机制不管叫 Claude Code Skill、Cursor Skill 还是企业内部的 agent skill本质都一样一个 Skill 是一个目录里面通常有一个描述文件比如 SKILL.md说明这个技能是干什么的、什么时候用、怎么用然后配套一个或多个可执行脚本最常见的就是 Python。大模型Agent 读描述文件觉得某个用户请求应该调用某个技能就会以子进程方式去执行那个脚本。执行完拿 stdout 当结果继续组织回答。整个过程里脚本就是被一个 shell 拉起来的普通进程没人在它启动时给它颁发身份凭证。我最初想偷懒既然 Skill 是宿主程序调起来的宿主自己肯定有鉴权体系脚本里可以不搞了。后来同事提醒我一句话点醒了——“你的 Skill 包是要分发给别人的别人拿到脚本直接python3 scripts/xxx.py也能跑这时候你的鉴权在哪一层”确实脚本被复制出去后宿主程序的鉴权逻辑和它没有任何关系。脚本自身必须认为自己“没有人证”就不能跑。1.2 为什么框架鉴权到不了子进程很多人第一次写 Skill 都会踩这个思维误区总以为“用户的登录态、宿主的 API Key”能在脚本里直接用。实际上宿主进程通过subprocess.run()拉起 Python 时默认只继承环境变量、文件句柄和当前工作目录用户态、会话 Token、数据库连接这些都不会自动出现。举个例子。你在 Web 应用里写接口可以依赖网关解析 JWT把用户信息放到请求头里再传给业务代码。但 Skill 的 Python 脚本不是 HTTP 接口它甚至可以脱离宿主直接从终端被调用。这时候你必须在脚本入口自己校验“调用者是谁、参数有没有被篡改、请求是不是重放”。换句话说鉴权的位置应该往内收一层收到每个脚本自己身上而不是指望外围框架替你兜底。2. 鉴权方案怎么选本地子进程场景轻量可信才是王道2.1 先把威胁模型列清楚我习惯动手之前先列威胁模型不然容易过度设计。Skill 里的 Python 脚本主要面对这几类风险未授权调用别人拿到了 Skill 目录直接执行脚本白嫖你的业务逻辑。这不只是安全问题还涉及你的算法、规则、甚至密钥被反推。参数伪造脚本的业务参数往往是用户输入透传过来的。如果脚本直接信任 stdin 或 argv攻击者可以构造任意 payload绕过宿主侧本来应该做的一些限制。密钥静态泄漏脚本里要调用第三方 API比如调一个付费接口API Key 如果硬编码在.py文件里Skill 包一分发密钥就全泄露了。重放攻击某次合法调用被完整记录下来之后原样重放造成重复扣费或者数据被重复写入。威胁模型一旦清楚方案选型就简单了。2.2 候选方案对比我为什么选了 HMAC 签名而不是 OAuth列一下我考虑过的几个方案方案优点缺点Skill 场景结论用户名密码实现简单子进程非交互没法输入密码存哪都是问题不合适静态 TokenBearer Token简单粗暴一旦泄漏永久有效没有防重放能力可以打底但不够HMAC 签名 时间戳无状态、防篡改、防重放密钥不出子进程环境需要宿主和脚本约定签名格式稍微多写几行代码推荐我最终用的就是它JWT自带过期时间、可携带身份要维护签名密钥对本地脚本来说偏重可用但没必要OAuth / SSO功能全依赖认证服务器本地离线场景直接瘫痪杀鸡用牛刀绝对不选我最终选的是环境变量注入身份标识 HMAC-SHA256 签名 时间戳防重放 密钥文件权限锁。这里解释一下为什么密钥不放环境变量而是放文件。子进程的环境变量可以被同用户下的其他进程通过/proc/pid/environ读到把高权限密钥长期放在环境变量里等于裸奔。所以我的设计是宿主侧持有密钥从安全存储读取生成签名后传给子进程子进程侧从权限受限的配置目录比如~/.config/skill_name/client.key读取同一个密钥做校验。这样即使/proc环境被读走也只是签名和令牌信息拿不到密钥本体。3. 实操落地给 Skill 的 Python 脚本套一层可复用的鉴权层3.1 目录结构与密钥放置先看 Skill 包的标准结构以及密钥文件应该放在哪my_skill/ ├── SKILL.md # 技能描述大模型靠它决定何时调用 └── scripts/ ├── secure_action.py # 带鉴权的业务脚本这套方案要讲的 └── other_tool.py # 另一个脚本 # 密钥不放在 Skill 包目录里而是放在用户配置目录 # ~/.config/my_skill/client.key Linux/macOS # %APPDATA%/my_skill/client.key Windows密钥不进 Skill 包是这条方案里最要紧的一条纪律。Skill 包是要分发、共享、被各种环境拷贝的里面但凡多一个secret.key文件就等于把家底送给别人。正确姿势是安装 Skill 时或首次运行时引导用户生成密钥放到用户配置目录。生成密钥可以这么搞一次搞定权限问题# Linux / macOS umask 077 openssl rand -hex 32 ~/.config/my_skill/client.keyumask 077保证创建文件时其他用户没有任何权限最终文件权限是 600只有当前用户能读。注意别写成先创建再chmod中间那几微妙文件权限可能是 644反正我脚本里会做二次校验后面说。3.2 签名生成与校验的完整代码整套鉴权分两半宿主生成签名脚本校验签名。两边必须用同一个规则否则你会被“签名对不上”逼疯。我先给宿主侧的调用代码。假设宿主是一个 Python Agent 框架它持有密钥并准备调用 Skill 里的脚本# host_side.py —— 宿主进程侧 import os import json import time import hmac import hashlib import subprocess # 密钥从安全存储读取这里用环境变量示意 SECRET os.environ[MY_SKILL_SECRET] def build_auth_env(payload: bytes) - dict: ts str(int(time.time())) # 签名内容 时间戳 . 原始payload message ts.encode(utf-8) b. payload signature hmac.new(SECRET.encode(utf-8), message, hashlib.sha256).hexdigest() env os.environ.copy() env[SKILL_RUNNER_ID] agent_main # 身份标识 env[SKILL_SIGN_TS] ts # 时间戳 env[SKILL_SIGNATURE] signature # 签名 return env def call_skill(payload: dict) - str: payload_bytes json.dumps(payload, ensure_asciiFalse).encode(utf-8) proc subprocess.run( [python3, scripts/secure_action.py], inputpayload_bytes, # 业务数据走 stdin capture_outputTrue, envbuild_auth_env(payload_bytes), timeout30, ) if proc.returncode ! 0: raise RuntimeError(fskill call failed: {proc.stderr.decode()}) return proc.stdout.decode(utf-8, errorsreplace) if __name__ __main__: print(call_skill({action: query, keyword: hello}))几个设计点单独拿出来说业务数据走 stdin 而不是 argv。命令行的参数长度有限制而且不同平台转义规则不一样中文参数在 Windows 上很容易变成乱码导致宿主和脚本两边的字节对不上。stdin 传字节流最稳。参与签名的是原始字节。payload_bytes就是json.dumps(...).encode(utf-8)的结果签名也基于同一份字节。只要脚本侧规规矩矩读 stdin 的原始字节做校验两边就不会因为字符串解码问题错位。密钥不放进子进程环境。子进程环境里只有时间戳和签名就算环境泄漏别人也只能重放有限的旧请求拿不到密钥本身。然后是脚本侧这才是鉴权的主体#!/usr/bin/env python3 # scripts/secure_action.py —— Skill 脚本侧 import os import sys import json import time import hmac import hashlib from pathlib import Path CONFIG_DIR os.environ.get( MY_SKILL_CONFIG_DIR, str(Path.home() / .config / my_skill), ) MAX_CLOCK_SKEW 300 # 允许 5 分钟时钟偏差 def load_secret() - str: 从受限配置目录读取密钥并强制校验文件权限。 secret_path Path(CONFIG_DIR) / client.key if not secret_path.exists(): raise PermissionError(fmissing secret: {secret_path}) if os.name posix: # Windows 上跳过权限位检查 mode secret_path.stat().st_mode 0o777 if mode 0o077: # 组或其他用户有任何权限都拒绝 raise PermissionError(secret permissions too loose, expected 0600) return secret_path.read_text(encodingutf-8).strip() def verify(payload: bytes, raw_ts: str, signature: str, secret: str) - bool: # 1. 校验签名 message raw_ts.encode(utf-8) b. payload expected hmac.new(secret.encode(utf-8), message, hashlib.sha256).hexdigest() if not hmac.compare_digest(signature.lower(), expected.lower()): return False # 2. 校验时间戳防止重放 try: ts int(raw_ts) except ValueError: return False if abs(int(time.time()) - ts) MAX_CLOCK_SKEW: return False return True def main(): # 第一步身份标识必须由宿主显式注入 runner os.environ.get(SKILL_RUNNER_ID, ) raw_ts os.environ.get(SKILL_SIGN_TS, ) signature os.environ.get(SKILL_SIGNATURE, ) if not (runner and raw_ts and signature): sys.stderr.write(auth: missing auth env\n) sys.exit(2) # 第二步读取密钥并校验签名 payload sys.stdin.buffer.read() try: secret load_secret() except PermissionError as exc: sys.stderr.write(fauth: {exc}\n) sys.exit(3) if not verify(payload, raw_ts, signature, secret): sys.stderr.write(auth: signature or timestamp invalid\n) sys.exit(4) # 第三步签名通过才允许执行业务逻辑 try: req json.loads(payload.decode(utf-8)) except (UnicodeDecodeError, json.JSONDecodeError): sys.stderr.write(auth: bad payload\n) sys.exit(5) # ---- 业务逻辑在这里开始 ---- action req.get(action, ) keyword req.get(keyword, ) if action query: result {ok: True, data: fhello {keyword}} else: result {ok: False, error: unsupported action} print(json.dumps(result, ensure_asciiFalse)) if __name__ __main__: main()这段代码把鉴权和业务逻辑放在同一个文件里看着挺直接。但更工程化的做法是把鉴权抽成公共模块比如scripts/_auth.py每个业务脚本只要from _auth import require_auth然后装饰一下入口函数就行。我在自己的 Skill 模板里就是这么做的新脚本只需要集中写业务不需要重复鉴权样板代码。3.3 密钥生命周期管理生成、轮换、吊销密钥不是生成一次就一劳永逸的。我整理了一套实用流程基本能覆盖日常需求生成上面给过命令umask 077 openssl rand -hex 32密钥至少 32 字节别自己拿拼音字符串当密钥。存储路径固定为~/.config/skill_name/client.key。脚本启动时校验文件存在性和权限位权限不是 600 就直接拒绝运行。轮换根据安全要求定期换密钥比如每月一次。轮换时先写新密钥到新文件再原子替换旧文件最后用新签名调用一次验证。吊销一旦怀疑密钥泄漏立刻重命名旧密钥文件并停掉脚本入口。注意Skill 脚本往往离线运行所以“吊销”要落到本地的黑名单机制。简单方案是密钥文件里同时存一个disabled标记或者在配置文件里记录允许的密钥指纹列表。这里有个坑要提醒如果 Skill 脚本要调外部 API那个外部 API 的 Key 仍然要由脚本从环境变量或用户配置里读取不要在 Skill 包里硬编码。外部 Key 和刚才说的client.key是两回事client.key用来证明“调用者可信”外部 Key 用来证明“这个脚本有权限使用第三方服务”。混为一谈会让你的鉴权边界一团糟。4. 踩坑记录与排查工具这些细节能救命4.1 我实际遇到过的 6 个典型问题再好的设计落地时候也会遇到各种想都想不到的问题。下面是我实测过程中踩过的坑按出现频率排序。现象原因解决办法宿主调用返回“signature or timestamp invalid”但手动生成签名又能通过宿主传给子进程的 payload 经过命令行或文件时编码被改了统一走 stdin 字节流确保payload_bytes是同一份字节Windows 上权限校验总是拒绝或总是跳过Windows 没有 POSIX 权限位os.name判断不准用os.name posix才检查权限Windows 上依赖目录 ACL脚本能跑但 Agent 一调用就报错宿主的subprocess.run没把环境变量传过去调用时envbuild_auth_env(...)不要偷懒只传部分变量日志里把 client.key 内容打出来了脚本异常栈引用到了密钥内容或调试时直接 print 环境变量所有加解密、鉴权日志只打长度、前几位禁止原始值重放旧请求居然成功没做时间戳校验或服务端本地时间偏差太大加上 MAX_CLOCK_SKEW 窗口定期校准脚本宿主机时间python3找不到脚本exit code 127宿主运行环境 PATH 和交互终端不一样用绝对路径调用 Python或给脚本写 shebang 并 chmod x第一条特别值得展开。我之前把业务数据放在 argv 里传递宿主sys.argv拿到 stringencode(utf-8)之后和脚本侧sys.argv[1].encode(utf-8)对不上。排查了半天发现是 Windows 下 argv 用了 GBK 编码、Unix 下又正常两套字节根本不一样。改成 stdin 原始字节之后跨平台问题一次解决。4.2 调试器怎么快速定位“签名到底哪里不对”排查鉴权问题最怕的就是盯着 hash 字符串瞎猜。我是这么做的脚本里加一个调试环境变量打开后打印签名摘要信息但不打印密钥原文# 在 main() 里、verify() 之前插入 if os.environ.get(SKILL_AUTH_DEBUG, 0) 1: sys.stderr.write( f[auth-debug] runner{runner} ts{raw_ts} fsig_len{len(signature)} sig_head{signature[:8]}\n )然后宿主侧故意传一个错误的签名打开调试再跑一次就能立刻确认如果sig_head和预期不一致问题在签名生成规则比如密钥不同或 payload 不同如果sig_head一致但校验失败问题在时间戳窗口或者verify里比较逻辑写错了。调试完记得把SKILL_AUTH_DEBUG从正式调用环境里去掉这东西在线上开着会让有心人拿到签名长度和前缀特征。4.3 抽成装饰器之后每个脚本只需要三行最后分享一个我现在的标准写法。把上面对_auth.py的引用简化一下业务脚本变成这种形态#!/usr/bin/env python3 # scripts/secure_action.py import json from _auth import require_auth require_auth def run(req: dict) - dict: # 这里直接写业务什么都不用管 return {ok: True, data: fhello {req.get(keyword, world)}} if __name__ __main__: print(json.dumps(run(json.loads(input())), ensure_asciiFalse))装饰器require_auth里就封装了环境变量校验、密钥读取、权限检查、签名校验、时间戳校验这一整套逻辑。任何新脚本接入鉴权成本从几十行变成三行。我自己整个 Skill 模板里已经把所有 Python 脚本都统一成了这个模式。将来维护的时候只用改_auth.py一处所有脚本同步升级。这种做法很朴素但在 Agent Skill 这种“脚本体量小、数量多、常分发”的场景里算是我用过最顺手的一套方案。如果你也在做 Skill 里的 Python 鉴权别一上来就上 OAuth先把我这套轻量方案拿去改一改大概率够用。
返回列表