🐎 第3章 授人以渔:Skills 工程实践
📄 一句话概括

allowed-tools 是写在文件头的一行权限清单:这个技能能用哪些工具、能跑哪些命令。 它是第一道筛子,不是安全边界——Bash 的前缀匹配拦不住「先跑合法命令、再接一条 rm」这种写法。

📝 本章和前面几章的关系

前面四节讲的是「读什么」,这一节讲「能干什么」。前者影响成本,后者影响后果。 这一节也是第 3 章前半的最后一块:结构、披露、触发、正文、权限,五件事凑齐,一个技能才算完整。

一、这个字段管什么

它写在 YAML 头里,一行字符串,逗号分隔。 触发时按这份清单收窄权限:清单外的工具,即使全局允许,在这个技能里也用不了。 所以它的作用不是「授权」,是「限权」。默认的权限来自全局配置,这个字段只是往下砍。 一个技能不写这个字段,等于继承全局权限——这也是为什么很多技能「什么都能干」,因为作者根本没写。

反过来,写得太紧会挡路:技能跑到第三步发现没有 Edit 权限,只能停下来问人,一次自动执行就断了。 判据只有一句:这个技能完成它的本职工作,最少需要哪些工具?多给一个都是风险,少给一个就干不成。

二、白名单与显式拒绝

清单可以写成两种语义:允许什么,或者拒绝什么。多数情况下写允许(白名单)就够了。 但有两类工具值得显式拒绝:删除类命令、系统路径的写操作。 写清楚比不写安全——不写是「没提」,写了是「明确不放」。 演示里的清单前六条是白名单(Read、Grep、两条 Bash 子命令、一条精确命令、Edit 一个目录), 后两条是显式拒绝(rm 类、系统绝对路径)。

要注意显式拒绝在有些实现里并不生效,它更像一份声明。真正拦住动作的,还是全局权限和钩子。 所以这一行的定位是「意图声明加第一道筛子」,不是最后一道闸门。

三、Bash 的精细控制语法

Bash 是最难收窄的一个工具,因为它什么都能干。收窄的办法是写子命令:

Bash(uv run alembic:*)      # 以 uv run alembic 开头的都放行
Bash(uv run pytest:*)       # 测试命令
Bash(git status)            # 只放行这一条,不带参数
Bash(git diff:*)            # diff 的任意参数

冒号加星号是前缀通配,不带星号是精确匹配。精确匹配最安全,但只适用于「不需要参数」的命令。 前缀通配的粒度要选对:uv run alembic:* 放行所有 alembic 子命令,uv run:* 就等于放行整个 uv。 差一个层级,范围差几十倍。 给 git:* 是最常见的坏写法:它包含 push、reset、checkout,一个技能能强推代码, 那这个技能就不该存在。

四、前缀匹配的漏洞

演示里有一行必须单独指出来:调用 uv run alembic downgrade base; rm -rf /,被判成放行。 原因很简单:它只看开头。参数以 uv run alembic 开头,后面的分号接什么都不过问。 这是前缀匹配的固有漏洞,不是实现 bug。 任何「看前缀」的规则都会被拼接绕过:分号、&&、管道、子 shell,四种写法都能接。

所以在 Bash 上用前缀匹配,就必须再配一条钩子做二次检查——检查完整命令行里有没有危险子串。 钩子的事在第 5 章讲。这一节只需要记住结论:权限清单挡不住刻意绕过,它挡的是「无意中乱来」。

五、三条原则

第一条:默认不给 Bash 通配。Bash(*) 等于把整个 shell 交出去,那这个技能跟没有约束没区别。 第二条:命令按子命令给,不给整个命令。 给 git status 而不是 git:*,给 uv run alembic:* 而不是 uv run:*。 第三条:写操作给目录,不给全盘。Edit(src*) 是能接受的;给所有文件等于没约束。

三条之外还有一条隐含的:别抄别人的清单。 别人的技能边界跟你的项目不一样,抄过来要么挡住自己的路,要么放开通往密钥的路。 抄清单比抄 CLAUDE.md 更危险:CLAUDE.md 写错了顶多让模型走弯路,权限写宽了是直接给它开了一道门。

六、动手 Demo:allowed-tools 怎么写,才既不挡路也不放水

先给一份八条的权限清单(六条白名单、两条显式拒绝),再拿十个调用逐个判。 判完看那一行漏网的,以及三条常见坏写法各自的问题。

RULES = [(u"Bash", u"uv run alembic:*", True),
         (u"Bash", u"uv run pytest:*",  True),
         (u"Bash", u"git status",       True),
         (u"Edit", u"src*",             True),
         (u"Bash", u"rm:*",             False)]

def ok(tool, arg):
    for t, pat, allow in RULES:
        if t != tool:
            continue
        if pat.endswith(u":*"):        # 前缀匹配:只看开头
            if arg.startswith(pat[:-2]):
                return allow
        elif arg == pat:
            return allow
    return False                        # 清单外一律不放

实跑输出:

--------------------------------------------------------------------
allowed-tools 怎么写,才既不挡路也不放水
--------------------------------------------------------------------


  ① 一份权限清单
  --------------------------------------------------------------------
| 规则 | 含义 | 效果 |
|---|---|---|
| Read | 读文件,任意路径 | 允许 |
| Grep | 搜内容,任意路径 | 允许 |
| Bash(uv run alembic:*) | 以 uv run alembic 开头的命令 | 允许 |
| Bash(uv run pytest:*) | 以 uv run pytest 开头的命令 | 允许 |
| Bash(git status) | 只允许 git status 这一条 | 允许 |
| Edit(src*) | src 下任意文件 | 允许 |
| Bash(rm:*) | 删除类命令一律不放 | 拒绝 |
| Write(/*) | 系统绝对路径一律不放 | 拒绝 |

  前六条是白名单,后两条是显式拒绝。
  真机上这个字段就是一行字符串,写在 SKILL.md 头部,管的是这个技能能用哪些工具。

  ② 十个调用,逐个判
  --------------------------------------------------------------------
| 工具 | 参数 | 结果 |
|---|---|---|
| Read | docs/install.md | 放行 |
| Bash | uv run alembic heads | 放行 |
| Bash | uv run alembic upgrade head | 放行 |
| Bash | uv run pytest -q | 放行 |
| Bash | git status | 放行 |
| Bash | git push --force | 拦下 |
| Bash | uv run alembic downgrade base; rm -rf / | 放行 |
| Edit | src/app/main.py | 放行 |
| Edit | docs/install.md | 拦下 |
| Write | /etc/passwd | 拦下 |


  ③ 有一行必须指出来
  --------------------------------------------------------------------
  第七个调用是 uv run alembic downgrade base 后面接一个 rm -rf。
  它被判成放行了——因为参数以 uv run alembic 开头。
  ! 这是前缀匹配的固有漏洞:它只看开头,不管后面接了什么。
  所以在 Bash 上要用前缀匹配,就必须配一条钩子做二次检查。权限清单不是安全边界,它只是第一道筛子。

  ④ 怎么写才合理
  --------------------------------------------------------------------
  第一条原则:默认不写 Bash 的通配。Bash(*) 等于把整个 shell 交出去,那这个技能跟没有约束没区别。
  第二条原则:命令按子命令给,不给整个命令。
  给 uv run alembic:* 而不是 uv run:*,给 git status 而不是 git:*。
  第三条原则:写操作给目录,不给全盘。Edit(src*) 是能接受的,Edit(所有文件) 等于没约束。
| 写法 | 问题 | 怎么改 |
|---|---|---|
| Bash(*) | 等于不设限 | 删掉,改成具体子命令 |
| Bash(git:*) | 能 push、能 reset | 拆分:只给 status、diff、log |
| Edit(任意路径) | 能改任何文件 | 限定到 src 或 docs |
| Write(系统目录) | 能写配置和密钥 | 永远别给 |

  ! 权限设计的判据只有一句:这个技能完成它的本职工作,最少需要哪些工具?
  多给一个都是风险,少给一个就干不成。「顺手都给了」是最常见的坏习惯。

①八条清单:四条 Bash 白名单(两条子命令前缀、一条精确命令)、Read 与 Grep、Edit 一个目录, 加两条显式拒绝。 前六条是白名单,后两条是显式拒绝。 真机上这个字段就是一行字符串,写在 SKILL.md 头部,管的是这个技能能用哪些工具。 ②十个调用里判错了两个方向的问题:一个该拦的放行了、两个该放的拦下了——严格说,只有第七个是真漏洞。 第七个是 uv run alembic downgrade base; rm -rf /。 它被判成放行,因为参数以 uv run alembic 开头。 ③这一行是整节的靶子:前缀匹配的固有漏洞,它只看开头,不管后面接了什么。 所以在 Bash 上用前缀匹配,就必须配一条钩子做二次检查。权限清单不是安全边界,它只是第一道筛子。 ④三条原则:不给 Bash 通配、命令按子命令给、写操作给目录。 最后一张表把坏写法列全了:Bash(*) 等于不设限,git:* 能强推,Edit(任意路径) 能改任何文件, Write(系统目录) 能写配置和密钥。 判据还是那一句:完成本职工作最少需要哪些工具。多给一个都是风险。

30 秒实验

  1. 把第七个调用改成分号、&&、管道三种写法,重跑一遍。三种都能绕过吗?哪种能被前缀匹配挡住?
  2. 把 Bash(uv run alembic:*) 换成 Bash(uv run alembic upgrade:*),十个调用里还有几个放行? 这样收窄的代价是什么?
  3. 给这个技能加一条 Write(reports*),判断它是白名单还是白给。 如果技能真的需要写报告,这条该怎么写才安全?

常见误区

  1. 把权限清单当成安全边界。它挡得住无意乱来,挡不住刻意拼接;真正的拦截要靠钩子。
  2. 写 Bash(*) 图省事。这等于把整个 shell 交出去,技能跟没约束一样。
  3. 给 git:*。它包含 push、reset、checkout,一个「查状态」的技能不该有强推的能力。
  4. 不写 allowed-tools,靠全局权限兜底。技能会继承全部权限,比写宽了更危险。
  5. 抄别人的权限清单。边界跟项目强相关,抄过来不是挡路就是开路。
自测三问(点开对答案) 1. allowed-tools 的作用是授权还是限权? —— 限权。默认权限来自全局配置,这个字段只是在全局的基础上往下砍,不写就等于继承全部。 2. 为什么 `Bash(uv run alembic:*)` 拦不住 `uv run alembic downgrade base; rm -rf /`? —— 因为它是前缀匹配,只看参数开头。 分号后面的内容不在判断范围里,所以整条被判成以 `uv run alembic` 开头而放行。 3. Bash 权限的三条写法原则是什么? —— 不给通配(不写 `Bash(*)`);命令按子命令给而不是整个命令;写操作限定到目录而不是全盘。

第 3 章前半到此为止:结构、披露、触发、正文、权限五件事齐了。下一节开始加功能——参数怎么传。

相关笔记