feat(skill): 技能治理 v0.2 —— 分桶/调用模式/校验/安全/清单/安装矩阵
- 新增 SCOPE.md 收录门槛 + .out-of-scope/ 拒绝档案 - 生命周期分桶 promoted/beta/deprecated,marketplace 只发布 promoted - 调用模式 model-invoked/user-invoked(user-invoked 不占模型目录) - 新增脚本 validate-skills.py / generate-catalog.py / scan-skill.sh(+allowlist) - 新增 beta 技能 skill-creator、ai-proj-helper-entry、verification-before-completion(各带 evals) - install-skills.sh: --agent dsh / --link / --audit / --include-beta / --no-validate - 融合:dev-review 接收评审意见、req-design 依赖边、agent-swarm 并行编排、dev-test TDD - catalog.json 61 个技能(58 promoted / 3 beta);validate 0 错误;scan 0 高危;现有测试通过
This commit is contained in:
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate catalog.json — the reviewable list of every skill in this repo.
|
||||
|
||||
Usage: python3 scripts/generate-catalog.py [--check]
|
||||
(default) write catalog.json
|
||||
--check fail if catalog.json is out of date (for CI)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CATALOG = ROOT / "catalog.json"
|
||||
|
||||
|
||||
def frontmatter(text: str) -> dict:
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return {}
|
||||
data: dict = {}
|
||||
metadata: dict = {}
|
||||
key = None
|
||||
for line in text[3:end].splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
data[key] = m.group(2).strip().strip('"').strip("'")
|
||||
continue
|
||||
m = re.match(r"^\s+([A-Za-z0-9_-]+):\s*(.*)$", line)
|
||||
if m and key == "metadata":
|
||||
metadata[m.group(1)] = m.group(2).strip().strip('"').strip("'")
|
||||
if metadata:
|
||||
data["metadata"] = metadata
|
||||
return data
|
||||
|
||||
|
||||
def build() -> dict:
|
||||
skills = []
|
||||
for plugin_json in sorted(ROOT.glob("skills-*/*-plugin/.claude-plugin/plugin.json")):
|
||||
plugin_dir = plugin_json.parent.parent
|
||||
meta = json.loads(plugin_json.read_text(encoding="utf-8"))
|
||||
skill_files = sorted((plugin_dir / "skills").rglob("SKILL.md"))
|
||||
if not skill_files:
|
||||
continue
|
||||
skill_md = skill_files[0]
|
||||
fm = frontmatter(skill_md.read_text(encoding="utf-8", errors="replace"))
|
||||
desc = fm.get("description", "")
|
||||
body = skill_md.read_text(encoding="utf-8", errors="replace")
|
||||
skills.append(
|
||||
{
|
||||
"name": meta.get("install_name") or fm.get("name"),
|
||||
"category": meta.get("dir_category"),
|
||||
"version": meta.get("version"),
|
||||
"status": meta.get("status", "promoted"),
|
||||
"invocation": (fm.get("metadata") or {}).get("invocation", "model-invoked"),
|
||||
"description": desc,
|
||||
"description_len": len(desc),
|
||||
"path": str(skill_md.relative_to(ROOT)),
|
||||
"digest": hashlib.sha256(body.encode("utf-8")).hexdigest()[:16],
|
||||
}
|
||||
)
|
||||
counts: dict = {}
|
||||
for skill in skills:
|
||||
key = f"{skill['status']}/{skill['invocation']}"
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return {
|
||||
"repo": "ai-proj-helper",
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"total": len(skills),
|
||||
"counts": counts,
|
||||
"skills": skills,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
catalog = build()
|
||||
text = json.dumps(catalog, ensure_ascii=False, indent=2) + "\n"
|
||||
if "--check" in sys.argv:
|
||||
if not CATALOG.is_file():
|
||||
print("catalog.json 不存在,请运行 python3 scripts/generate-catalog.py")
|
||||
return 1
|
||||
current = json.loads(CATALOG.read_text(encoding="utf-8"))
|
||||
current.pop("generated_at", None)
|
||||
fresh = dict(catalog)
|
||||
fresh.pop("generated_at", None)
|
||||
if current != fresh:
|
||||
print("catalog.json 已过期,请重新生成")
|
||||
return 1
|
||||
print("catalog.json 是最新的")
|
||||
return 0
|
||||
CATALOG.write_text(text, encoding="utf-8")
|
||||
print(f"写入 {CATALOG.relative_to(ROOT)}:{catalog['total']} 个技能 {catalog['counts']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
# 安全扫描允许清单(人工确认过的误报)
|
||||
# 格式:<路径子串>::<匹配文本中的特征串>
|
||||
# 例:某技能文档把危险命令当作“禁止清单”引用,而非真的要执行。
|
||||
skills-dev/slark-cicd-plugin::NEVER_AUTO
|
||||
skills-dev/slark-cicd-plugin::destructive
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# scan-skill.sh — security scan before a skill enters the team library.
|
||||
#
|
||||
# Usage: ./scripts/scan-skill.sh [plugin-dir ...]
|
||||
# With no arguments scans every skills-*/*-plugin directory.
|
||||
# Exit 0 = clean (warnings allowed), 1 = high severity finding.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
HIGH=0
|
||||
WARN=0
|
||||
|
||||
# High severity: destructive, remote-code-execution, secret material.
|
||||
HIGH_PATTERNS=(
|
||||
'rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+/($|[[:space:]])'
|
||||
'curl[^|]*\|[[:space:]]*(ba|z)?sh'
|
||||
'wget[^|]*\|[[:space:]]*(ba|z)?sh'
|
||||
'chmod[[:space:]]+777'
|
||||
'BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY'
|
||||
'sk-[A-Za-z0-9]{16,}'
|
||||
'AKIA[0-9A-Z]{16}'
|
||||
'ghp_[A-Za-z0-9]{20,}'
|
||||
)
|
||||
# Warnings: machine-specific paths, plain http endpoints.
|
||||
WARN_PATTERNS=(
|
||||
'/Users/[A-Za-z0-9._-]+/'
|
||||
'http://[A-Za-z0-9.-]+'
|
||||
'password[[:space:]]*=[[:space:]]*[^$]'
|
||||
)
|
||||
|
||||
targets=("$@")
|
||||
if [[ ${#targets[@]} -eq 0 ]]; then
|
||||
while IFS= read -r dir; do
|
||||
targets+=("$(dirname "$(dirname "$dir")")")
|
||||
done < <(find "$ROOT" -path "*/skills-*/*-plugin/.claude-plugin/plugin.json" | sort)
|
||||
fi
|
||||
|
||||
ALLOWLIST="$ROOT/scripts/scan-allowlist.txt"
|
||||
is_allowlisted() {
|
||||
# is_allowlisted <abs_path> <matched_line>
|
||||
local path="$1" line="$2" entry sub needle
|
||||
[[ -f "$ALLOWLIST" ]] || return 1
|
||||
while IFS= read -r entry; do
|
||||
[[ -z "$entry" || "$entry" == \#* ]] && continue
|
||||
sub="${entry%%::*}"
|
||||
needle="${entry#*::}"
|
||||
if [[ "$path" == *"$sub"* && "$line" == *"$needle"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
done < "$ALLOWLIST"
|
||||
return 1
|
||||
}
|
||||
|
||||
for dir in "${targets[@]}"; do
|
||||
rel="${dir#"$ROOT"/}"
|
||||
for pattern in "${HIGH_PATTERNS[@]}"; do
|
||||
while IFS= read -r hit; do
|
||||
abs="${hit%%:*}"
|
||||
detail="${hit#*:}"
|
||||
if is_allowlisted "$abs" "$hit"; then
|
||||
echo "[allow] $rel: $detail"
|
||||
continue
|
||||
fi
|
||||
echo "[HIGH] $rel: $detail"
|
||||
HIGH=$((HIGH + 1))
|
||||
done < <(grep -rnE --include='*.md' --include='*.sh' --include='*.py' --include='*.js' --include='*.ts' --include='*.json' -- "$pattern" "$dir" 2>/dev/null | cut -c1-200)
|
||||
done
|
||||
for pattern in "${WARN_PATTERNS[@]}"; do
|
||||
while IFS= read -r hit; do
|
||||
echo "[warn] $rel: ${hit%%:*}:${hit#*:}"
|
||||
WARN=$((WARN + 1))
|
||||
done < <(grep -rnE --include='*.md' --include='*.sh' --include='*.py' --include='*.js' --include='*.ts' -- "$pattern" "$dir" 2>/dev/null | cut -c1-200)
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "扫描完成:${#targets[@]} 个插件,$HIGH 个高危,$WARN 个警告"
|
||||
if [[ "$HIGH" -gt 0 ]]; then
|
||||
echo "存在高危项,禁止入库,除非逐条确认并记录原因。"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate plugins and SKILL.md files against the Agent Skills spec + team rules.
|
||||
|
||||
Usage: python3 scripts/validate-skills.py [--strict]
|
||||
Exit 0 = pass, 1 = errors found.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ALLOWED_FRONTMATTER = {
|
||||
"name", "description", "license", "compatibility", "metadata",
|
||||
"allowed-tools", "disable-model-invocation", "version",
|
||||
}
|
||||
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
BODY_MAX_BYTES = 20 * 1024
|
||||
DESC_MIN, DESC_MAX = 30, 1024
|
||||
STATUSES = {"promoted", "beta", "deprecated"}
|
||||
INVOCATIONS = {"model-invoked", "user-invoked"}
|
||||
REQUIRED_PLUGIN_FIELDS = ("install_name", "install_type", "dir_category", "version")
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
def parse_frontmatter(text: str):
|
||||
"""Return (frontmatter dict, body text). Supports one level of nesting."""
|
||||
if not text.startswith("---"):
|
||||
return {}, text
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return {}, text
|
||||
raw, body = text[3:end], text[end + 4:]
|
||||
data: dict = {}
|
||||
metadata: dict = {}
|
||||
key = None
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
data[key] = m.group(2).strip().strip('"').strip("'")
|
||||
continue
|
||||
m = re.match(r"^\s+([A-Za-z0-9_-]+):\s*(.*)$", line)
|
||||
if m and key == "metadata":
|
||||
metadata[m.group(1)] = m.group(2).strip().strip('"').strip("'")
|
||||
elif key:
|
||||
data[key] = (data.get(key, "") + " " + line.strip()).strip()
|
||||
if metadata:
|
||||
data["metadata"] = metadata
|
||||
return data, body
|
||||
|
||||
|
||||
def check_plugin(plugin_json: Path):
|
||||
plugin_dir = plugin_json.parent.parent
|
||||
rel = plugin_dir.relative_to(ROOT)
|
||||
try:
|
||||
meta = json.loads(plugin_json.read_text(encoding="utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"{rel}: plugin.json 解析失败: {exc}")
|
||||
return
|
||||
|
||||
for field in REQUIRED_PLUGIN_FIELDS:
|
||||
if not meta.get(field):
|
||||
errors.append(f"{rel}: plugin.json 缺少 {field}")
|
||||
status = meta.get("status", "")
|
||||
if status and status not in STATUSES:
|
||||
errors.append(f"{rel}: status 非法 {status!r}(应为 {sorted(STATUSES)})")
|
||||
install_name = meta.get("install_name", "")
|
||||
if install_name and not NAME_RE.match(install_name):
|
||||
errors.append(f"{rel}: install_name 不合规 {install_name!r}")
|
||||
|
||||
skills_dir = plugin_dir / "skills"
|
||||
skill_files = sorted(skills_dir.rglob("SKILL.md")) if skills_dir.is_dir() else []
|
||||
if not skill_files:
|
||||
errors.append(f"{rel}: 没有找到 skills/**/SKILL.md")
|
||||
return
|
||||
for skill_md in skill_files:
|
||||
check_skill(rel, skill_md, meta)
|
||||
|
||||
|
||||
def check_skill(rel: Path, skill_md: Path, meta: dict):
|
||||
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
||||
fm, body = parse_frontmatter(text)
|
||||
where = f"{rel}/{skill_md.name}"
|
||||
|
||||
name = fm.get("name", "")
|
||||
if not name:
|
||||
errors.append(f"{where}: frontmatter 缺少 name")
|
||||
elif not NAME_RE.match(name):
|
||||
errors.append(f"{where}: name 不合规 {name!r}(小写字母/数字/连字符,≤64)")
|
||||
|
||||
desc = fm.get("description", "")
|
||||
if not desc:
|
||||
errors.append(f"{where}: frontmatter 缺少 description")
|
||||
elif not (DESC_MIN <= len(desc) <= DESC_MAX):
|
||||
warnings.append(f"{where}: description 长度 {len(desc)},建议 {DESC_MIN}-{DESC_MAX}(决定是否被触发)")
|
||||
|
||||
for key in fm:
|
||||
if key == "metadata":
|
||||
continue
|
||||
if key not in ALLOWED_FRONTMATTER:
|
||||
warnings.append(f"{where}: 非标准 frontmatter 字段 {key!r},建议移入 metadata")
|
||||
|
||||
body_bytes = len(body.encode("utf-8"))
|
||||
if body_bytes > BODY_MAX_BYTES:
|
||||
warnings.append(f"{where}: 正文 {body_bytes} 字节,超过 20KB 预算,建议拆 references/")
|
||||
|
||||
metadata = fm.get("metadata") or {}
|
||||
invocation = metadata.get("invocation", "")
|
||||
if invocation and invocation not in INVOCATIONS:
|
||||
errors.append(f"{where}: metadata.invocation 非法 {invocation!r}")
|
||||
disable_model = str(fm.get("disable-model-invocation", "")).lower() == "true"
|
||||
if invocation == "user-invoked" and not disable_model:
|
||||
errors.append(f"{where}: user-invoked 技能必须设置 disable-model-invocation: true")
|
||||
if invocation == "model-invoked" and disable_model:
|
||||
errors.append(f"{where}: model-invoked 技能不应设置 disable-model-invocation: true")
|
||||
|
||||
status = meta.get("status", "")
|
||||
if status == "promoted":
|
||||
docs = ROOT / "docs" / "skills" / f"{meta.get('install_name', name)}.md"
|
||||
if not docs.is_file():
|
||||
warnings.append(f"{where}: promoted 技能缺少四段式文档 docs/skills/{docs.name}")
|
||||
|
||||
# Relative links must resolve.
|
||||
for target in re.findall(r"\]\((\.[^)\s]+|[A-Za-z0-9_-]+/[^)\s]+)\)", body):
|
||||
if target.startswith(("http://", "https://", "#")):
|
||||
continue
|
||||
if not (skill_md.parent / target).exists():
|
||||
warnings.append(f"{where}: 引用的相对路径不存在: {target}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
strict = "--strict" in sys.argv
|
||||
plugins = sorted(ROOT.glob("skills-*/*-plugin/.claude-plugin/plugin.json"))
|
||||
if not plugins:
|
||||
print("未找到任何 plugin.json", file=sys.stderr)
|
||||
return 1
|
||||
for plugin_json in plugins:
|
||||
check_plugin(plugin_json)
|
||||
|
||||
for warning in warnings:
|
||||
print(f"[warn] {warning}")
|
||||
for error in errors:
|
||||
print(f"[error] {error}")
|
||||
print(f"\n检查 {len(plugins)} 个插件:{len(errors)} 个错误,{len(warnings)} 个警告")
|
||||
if errors:
|
||||
return 1
|
||||
if strict and warnings:
|
||||
print("--strict 模式下,警告视为失败")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user