离线审查 SKILL.md:找出不受限 Bash 授权并给出收窄规则

参考 agent-skills-state-2026 的 10 月 26 日报告,用 8 个内置 mock 技能演示:解析前言、识别裸 Bash / Bash(*) 授权、找出没用上的授权、标记较高风险命令,并按正文命令给出 Bash(命令:*) 形式的收窄建议。

已验证 Python 3.12.15 耗时 不到 0.1s 运行于 2026-10-27 13:10 UTC
结论

5 个不受限 Bash 授权中 1 个可直接删除,其余可按命令收窄

检查 12/12 通过
  • scoped-git: parse='ok', grant='scoped', risks=set()
  • unused-bash: parse='ok', grant='unscoped', unused=True, risks=set()
  • formatter: parse='ok', grant='unscoped', unused=False, risks={'install'}
  • release: parse='ok', grant='unscoped', unused=False, risks={'publish', 'delete'}
  • bootstrap: parse='ok', grant='unscoped', unused=False, risks={'sudo', 'pipe-shell', 'http'}
  • env-loader: parse='ok', grant='unscoped', unused=False, risks={'credential', 'eval'}
  • bad-yaml: parse='error'
  • no-frontmatter: parse='missing'
  • formatter: 收窄后的规则覆盖了正文中的全部命令
  • release: 收窄后的规则覆盖了正文中的全部命令
  • bootstrap: 收窄后的规则覆盖了正文中的全部命令
  • env-loader: 收窄后的规则覆盖了正文中的全部命令
实验指标(个)
技能总数8个
前言无法解析或缺失2个
不受限 Bash 授权5个
授权未用上1个
带较高风险命令4个

数值取自 GitHub Actions 实际运行输出中的 METRIC 行

实验指标(%)
不受限授权中未用上的比例20.0%
用到的不同命令不超过 3 个的比例75.0%

数值取自 GitHub Actions 实际运行输出中的 METRIC 行

以上内容从实际运行输出中提取,完整输出见下方。

python main.py
== 审查结果 ==
- scoped-git: 授权 scoped
- unused-bash: 授权 unscoped;正文无命令且无脚本 → 建议从副本中删掉 Bash
- formatter: 授权 unscoped;建议改为 Bash(npx prettier:*);风险:install
- release: 授权 unscoped;建议改为 Bash(git push:*), Bash(npm publish:*), Bash(npm run:*), Bash(rm:*);风险:delete, publish
- bootstrap: 授权 unscoped;建议改为 Bash(curl:*), Bash(sudo cp:*);风险:http, pipe-shell, sudo
- env-loader: 授权 unscoped;建议改为 Bash(cat:*), Bash(eval:*);风险:credential, eval
- bad-yaml: 前言 error,宿主可能读不到技能头部,先修前言
- no-frontmatter: 前言 missing,宿主可能读不到技能头部,先修前言

== 检查 ==
[PASS] scoped-git: parse='ok', grant='scoped', risks=set()
[PASS] unused-bash: parse='ok', grant='unscoped', unused=True, risks=set()
[PASS] formatter: parse='ok', grant='unscoped', unused=False, risks={'install'}
[PASS] release: parse='ok', grant='unscoped', unused=False, risks={'publish', 'delete'}
[PASS] bootstrap: parse='ok', grant='unscoped', unused=False, risks={'sudo', 'pipe-shell', 'http'}
[PASS] env-loader: parse='ok', grant='unscoped', unused=False, risks={'credential', 'eval'}
[PASS] bad-yaml: parse='error'
[PASS] no-frontmatter: parse='missing'
[PASS] formatter: 收窄后的规则覆盖了正文中的全部命令
[PASS] release: 收窄后的规则覆盖了正文中的全部命令
[PASS] bootstrap: 收窄后的规则覆盖了正文中的全部命令
[PASS] env-loader: 收窄后的规则覆盖了正文中的全部命令

METRIC: 技能总数 = 8 个
METRIC: 前言无法解析或缺失 = 2 个
METRIC: 不受限 Bash 授权 = 5 个
METRIC: 授权未用上 = 1 个
METRIC: 带较高风险命令 = 4 个
METRIC: 不受限授权中未用上的比例 = 20.0 %
METRIC: 用到的不同命令不超过 3 个的比例 = 75.0 %
RESULT: 5 个不受限 Bash 授权中 1 个可直接删除,其余可按命令收窄
依赖版本(1)
PyYAML==6.0.2

目的

agent-skills-state-2026 的 10 月 26 日跟进报告 Skills with unscoped Bash 分析了在 allowed-tools 里申请裸 Bash 或 Bash(*) 的公开技能,发现不少授权根本没用上,用到的命令也往往只有几条。报告建议:没有命令和脚本的技能直接删掉 Bash;其余改成只允许文档中出现的命令,例如 Bash(git status:*)。

本实验把这几条建议写成一个只用正则和 YAML 解析的小审查器,在 8 个脚本内置的 mock 技能上跑一遍,看它能否:

  1. 识别前言(front matter)缺失或无法解析,例如 description: Use when: ... 这种未加引号的冒号
  2. 区分不受限授权(Bash、Bash(*))和已收窄的授权(Bash(git status:*))
  3. 找出「申请了 Bash,但正文没有 shell 命令、也不带脚本」的技能
  4. 标记较高风险命令:管道下载到 shell、直接 HTTP、安装包、递归删除、eval、推送/发布、sudo、读取 .env 等
  5. 按正文命令给出收窄后的 Bash(命令:*) 规则,并检查这些规则覆盖了每一条命令

注意:风险规则是本实验自己写的近似版本,不是报告原文的 9 条规则;样本是人工构造的 mock 数据,不复现报告中的任何数字。

步骤

pip install -r requirements.txt
python main.py

程序打印每个技能的审查结论、[PASS]/[FAIL] 检查、METRIC: 指标和最后一行 RESULT:,任一检查失败时以非零状态退出。

预期结果

  • 8 个 mock 技能的判定都与脚本里人工写定的期望标签一致
  • bad-yaml 被判为前言解析失败,no-frontmatter 被判为缺少前言
  • unused-bash 被判为授权未用上,建议直接删除 Bash
  • 其余不受限授权都能给出收窄规则,且规则覆盖正文中的全部命令

启示与可以继续尝试

  • 把团队已装技能的 SKILL.md 内容替换进 SKILLS,看看有多少授权可以删掉或收窄
  • 收窄建议只按命令的前一两个词生成;像 eval、sudo、管道下载这类命令,更合适的做法是改写技能本身,而不是把它们加进白名单
  • 报告提醒:审查只看页面和技能目录里的脚本,Agent 运行时可能执行别的命令;技能目录外的脚本也要读
  • 宿主对 allowed-tools 的写法(逗号分隔字符串或列表)可能不同,以所用 Agent 的文档为准

内网复现

  • 依赖只有 PyYAML,可通过内网 PyPI 镜像安装:pip install -i <内网镜像地址> -r requirements.txt
  • 程序完全离线运行,不访问任何网络,也不需要 API key
  • 如果内网镜像没有 PyYAML,可以把审查器里的 yaml.safe_load 换成只解析 key: value 的简单实现,但这样就测不出「未加引号的冒号」这类前言错误
  • 报告原文和数据集在 GitHub 上,需要外网或内网 Git 镜像获取;本实验不依赖它们

代码

main.py286 行
"""离线审查 SKILL.md:找出不受限的 Bash 授权、没用上的授权和较高风险命令。

规则参考 agent-skills-state-2026 的 10 月 26 日跟进报告(skills-with-unscoped-bash.md):
- 裸 `Bash` 或 `Bash(*)` 视为不受限;`Bash(git status:*)` 这类视为已收窄
- 正文没有 shell 命令、也不带脚本的技能,Bash 授权等于没用上
- 用正则标记几类较高风险行为

报告原文用 9 条正则规则,这里的规则是本实验自己写的近似版本,样本也是脚本内置的
mock 技能,只演示审查方法,不复现报告中的任何数字。
"""
from __future__ import annotations

import re
import sys

import yaml

# ---------------------------------------------------------------------------
# mock 技能:name -> (SKILL.md 文本, 是否带脚本, 期望标签)
# 期望标签由人工写定,用来检查审查器的判断是否正确
# ---------------------------------------------------------------------------
SKILLS = {
    "scoped-git": (
        """---
name: scoped-git
description: Summarize the working tree before a commit.
allowed-tools: Bash(git status:*), Bash(git diff:*)
---
Run these and summarize:

```bash
git status
git diff --stat
```
""",
        False,
        {"parse": "ok", "grant": "scoped", "risks": set()},
    ),
    "unused-bash": (
        """---
name: unused-bash
description: Review a pull request description for clarity.
allowed-tools: Read, Bash
---
Read the PR description and point out unclear sentences. Suggest a rewrite.
""",
        False,
        {"parse": "ok", "grant": "unscoped", "unused": True, "risks": set()},
    ),
    "formatter": (
        """---
name: formatter
description: Format the project.
allowed-tools: Bash(*)
---
```sh
npx prettier --write .
```
""",
        False,
        {"parse": "ok", "grant": "unscoped", "unused": False, "risks": {"install"}},
    ),
    "release": (
        """---
name: release
description: Build and publish a release.
allowed-tools: [Read, Bash]
---
```bash
rm -rf dist
npm run build
git push origin main --tags
npm publish
```
""",
        False,
        {"parse": "ok", "grant": "unscoped", "unused": False, "risks": {"delete", "publish"}},
    ),
    "bootstrap": (
        """---
name: bootstrap
description: Set up the toolchain on a new machine.
allowed-tools: Bash
---
```bash
curl -fsSL https://example.com/install.sh | sh
sudo cp ./bin/tool /usr/local/bin/
```
""",
        False,
        {"parse": "ok", "grant": "unscoped", "unused": False,
         "risks": {"pipe-shell", "http", "sudo"}},
    ),
    "env-loader": (
        """---
name: env-loader
description: Load environment for local runs.
allowed-tools: Bash
---
```bash
cat .env
eval "$(./scripts/print-env.sh)"
```
""",
        True,
        {"parse": "ok", "grant": "unscoped", "unused": False, "risks": {"credential", "eval"}},
    ),
    "bad-yaml": (
        """---
name: bad-yaml
description: Use when: the user asks for a changelog
allowed-tools: Bash(git log:*)
---
```bash
git log --oneline -20
```
""",
        False,
        {"parse": "error"},
    ),
    "no-frontmatter": (
        """# Notes skill

Write meeting notes in the team template.
""",
        False,
        {"parse": "missing"},
    ),
}

SHELL_LANGS = {"bash", "sh", "shell", "zsh", "console"}
FENCE = re.compile(r"```([A-Za-z0-9_-]*)\n(.*?)```", re.S)

# 本实验的近似规则(不是报告原文的规则)
RISK_RULES = {
    "pipe-shell": re.compile(r"\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba|z)?sh\b"),
    "http": re.compile(r"\b(curl|wget)\b|\bhttps?://"),
    "install": re.compile(r"\b(pip|pip3|npm|pnpm|yarn)\s+(install|add)\b|\bnpx\s|\buvx\s|\bapt(-get)?\s+install\b|\bbrew\s+install\b"),
    "delete": re.compile(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\b|\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[a-zA-Z]*f"),
    "eval": re.compile(r"\beval\b"),
    "publish": re.compile(r"\bgit\s+push\b|\bnpm\s+publish\b|\btwine\s+upload\b"),
    "sudo": re.compile(r"\b(sudo|doas)\b"),
    "credential": re.compile(r"(^|\s)\.env\b|\.aws/credentials|\.netrc\b"),
    "sysconfig": re.compile(r"\s/etc/|\bsystemctl\b"),
}


def split_front_matter(text: str):
    """返回 (状态, 元数据);状态为 ok / error / missing。"""
    if not text.startswith("---\n"):
        return "missing", None
    end = text.find("\n---", 4)
    if end == -1:
        return "error", None
    try:
        meta = yaml.safe_load(text[4:end])
    except yaml.YAMLError:
        return "error", None
    return ("ok", meta) if isinstance(meta, dict) else ("error", None)


def tool_list(value) -> list[str]:
    if value is None:
        return []
    if isinstance(value, list):
        return [str(v).strip() for v in value]
    # 逗号分隔的字符串;括号里的逗号不拆
    return [t.strip() for t in re.split(r",\s*(?![^()]*\))", str(value)) if t.strip()]


def grant_kind(tools: list[str]) -> str:
    bash = [t for t in tools if t == "Bash" or t.startswith("Bash(")]
    if not bash:
        return "none"
    if any(t in ("Bash", "Bash(*)") for t in bash):
        return "unscoped"
    return "scoped"


def shell_lines(text: str) -> list[str]:
    lines = []
    for lang, body in FENCE.findall(text):
        if lang.lower() not in SHELL_LANGS:
            continue
        for line in body.splitlines():
            line = line.strip()
            if line.startswith("$ "):
                line = line[2:]
            if line and not line.startswith("#"):
                lines.append(line)
    return lines


def suggest_rule(line: str) -> str:
    """按命令的前一到两个词给出收窄后的规则。"""
    words = line.split()
    head = words[0]
    if len(words) > 1 and re.fullmatch(r"[a-z][a-z0-9-]*", words[1]):
        head = f"{words[0]} {words[1]}"
    return f"Bash({head}:*)"


def audit(text: str, has_scripts: bool) -> dict:
    status, meta = split_front_matter(text)
    out = {"parse": status}
    if status != "ok":
        return out
    out["grant"] = grant_kind(tool_list(meta.get("allowed-tools")))
    lines = shell_lines(text)
    out["commands"] = sorted({suggest_rule(l) for l in lines})
    out["unused"] = out["grant"] == "unscoped" and not lines and not has_scripts
    joined = "\n".join(lines)
    out["risks"] = {name for name, rx in RISK_RULES.items() if rx.search(joined)}
    return out


def main() -> int:
    results = {name: audit(text, scripts) for name, (text, scripts, _) in SKILLS.items()}
    checks: list[tuple[bool, str]] = []

    for name, (_, _, expect) in SKILLS.items():
        got = results[name]
        ok = all(got.get(k) == v for k, v in expect.items())
        detail = ", ".join(f"{k}={got.get(k)!r}" for k in expect)
        checks.append((ok, f"{name}: {detail}"))

    print("\n== 审查结果 ==")
    for name, r in results.items():
        if r["parse"] != "ok":
            print(f"- {name}: 前言 {r['parse']},宿主可能读不到技能头部,先修前言")
            continue
        line = f"- {name}: 授权 {r['grant']}"
        if r["unused"]:
            line += ";正文无命令且无脚本 → 建议从副本中删掉 Bash"
        elif r["grant"] == "unscoped":
            line += f";建议改为 {', '.join(r['commands'])}"
        if r["risks"]:
            line += f";风险:{', '.join(sorted(r['risks']))}"
        print(line)

    parsed = [r for r in results.values() if r["parse"] == "ok"]
    unscoped = [r for r in parsed if r["grant"] == "unscoped"]
    unused = [r for r in unscoped if r["unused"]]
    used = [r for r in unscoped if not r["unused"]]
    risky = [r for r in unscoped if r["risks"]]
    few = [r for r in used if len(r["commands"]) <= 3]
    total = len(SKILLS)

    # 收窄后的规则应覆盖正文里的每一条命令
    for name, r in results.items():
        if r.get("grant") != "unscoped" or r["unused"]:
            continue
        text = SKILLS[name][0]
        covered = all(
            any(l.startswith(rule[5:-3]) for rule in r["commands"]) for l in shell_lines(text)
        )
        checks.append((covered, f"{name}: 收窄后的规则覆盖了正文中的全部命令"))

    print("\n== 检查 ==")
    for ok, msg in checks:
        print(f"[{'PASS' if ok else 'FAIL'}] {msg}")

    print()
    print(f"METRIC: 技能总数 = {total} 个")
    print(f"METRIC: 前言无法解析或缺失 = {total - len(parsed)} 个")
    print(f"METRIC: 不受限 Bash 授权 = {len(unscoped)} 个")
    print(f"METRIC: 授权未用上 = {len(unused)} 个")
    print(f"METRIC: 带较高风险命令 = {len(risky)} 个")
    print(f"METRIC: 不受限授权中未用上的比例 = {100 * len(unused) / len(unscoped):.1f} %")
    print(f"METRIC: 用到的不同命令不超过 3 个的比例 = {100 * len(few) / len(used):.1f} %")

    failed = [m for ok, m in checks if not ok]
    if failed:
        print(f"RESULT: {len(failed)} 项检查未通过,审查规则需要调整")
        return 1
    print(
        f"RESULT: {len(unscoped)} 个不受限 Bash 授权中 {len(unused)} 个可直接删除,"
        f"其余 {len(used)} 个都能收窄到 3 条以内的具体命令规则"
        if len(few) == len(used)
        else f"RESULT: {len(unscoped)} 个不受限 Bash 授权中 {len(unused)} 个可直接删除,其余可按命令收窄"
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())
requirements.txt1 行
PyYAML==6.0.2
Esc
↑ ↓ 选择Enter 打开

键盘快捷键

j / k
下一条 / 上一条
o
展开或收起当前条目
Enter
打开当前条目详情
c
复制当前条目链接
s
收藏 / 取消收藏当前条目
/ 或 ⌘K
搜索
1 2 3
切换密度:标题 / 摘要 / 完整
t
切换主题
?
显示本帮助