Setting the file. One moment. Inventory Skills · Agent Skill Stack · github/awesome-copilot · Skills Docs11.10
OpenAI
scripts/inventory_skills.py
Python·271 lines·11 KB
14SKIP_DIRS = {
15 ".git",
16 ".archive",
17 ".curator_backups",
18 ".hub",
19 "__pycache__",
20 "node_modules",
21}
22SCRIPT_SUFFIXES = {".py", ".sh", ".js", ".ts", ".mjs", ".cjs", ".ps1", ".rb", ".go"}
23STOPWORDS = {
24 "about", "agent", "agents", "also", "and", "any", "are", "can", "for", "from",
25 "help", "into", "its", "other", "skill", "skills", "that", "the", "their", "this",
26 "through", "tool", "tools", "use", "user", "users", "using", "when", "with", "workflow",
27 "一个", "一款", "一些", "什么", "可以", "帮我", "技能", "我想", "有没有", "这个", "这件",
28}
29RISK_PATTERNS = {
30 "destructive-command": re.compile(r"\brm\s+-[^\n]*r[^\n]*f|git\s+reset\s+--hard|shutil\.rmtree", re.I),
31 "credential-or-secret-access": re.compile(
32 r"\.ssh\b|\.aws\b|keychain|credential|cookie|secret|api[_-]?key|\.env\b|os\.environ", re.I
33 ),
34 "network-or-download": re.compile(
35 r"\bcurl\b|\bwget\b|requests\.|httpx\.|urllib\.|fetch\s*\(|https?://", re.I
36 ),
37 "dynamic-or-obfuscated-execution": re.compile(
38 r"base64[^\n]{0,80}(decode|-d)|\beval\s*\(|\bexec\s*\(|child_process|subprocess\.", re.I
39 ),
40 "persistence-or-system-service": re.compile(r"\bcrontab\b|\blaunchctl\b|systemctl\s+enable|launchagents", re.I),
41 "privilege-or-broad-permission": re.compile(r"\bsudo\b|chmod\s+777|chown\s+-R", re.I),
42 "external-mutation-language": re.compile(
43 r"\b(publish|send|upload|delete|remove|purchase|comment|post)\b|发布|发送|上传|删除|购买|评论", re.I
44 ),
45 "possible-hardcoded-token": re.compile(r"\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]{12,}\b|\bAKIA[A-Z0-9]{12,}\b"),
46}
47
48
49def parse_frontmatter(text: str) -> tuple[dict[str, str], list[str]]:
50 issues: list[str] = []
51 lines = text.splitlines()
52 if not lines or lines[0].strip() != "---":
53 return {}, ["missing opening frontmatter delimiter"]
54 try:
55 end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---")
56 except StopIteration:
57 return {}, ["missing closing frontmatter delimiter"]
58
59 data: dict[str, str] = {}
60 i = 1
61 while i < end:
62 match = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", lines[i])
63 if not match:
64 i += 1
65 continue
66 key, raw = match.group(1), match.group(2).strip()
67 if raw in {">", "|"}:
68 mode = raw
69 block: list[str] = []
70 i += 1
71 while i < end and (not lines[i].strip() or lines[i][:1].isspace()):
72 block.append(lines[i].strip())
73 i += 1
74 data[key] = (" " if mode == ">" else "\n").join(part for part in block if part)
75 continue
76 if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}:
77 raw = raw[1:-1]
78 data[key] = raw
79 i += 1
80
81 if not data.get("name"):
82 issues.append("missing name")
83 if not data.get("description"):
84 issues.append("missing description")
85 return data, issues
86
87
88def iter_skill_files(root: Path) -> Iterable[Path]:
89 for current, dirs, files in os.walk(root, followlinks=False):
90 dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
91 if "SKILL.md" in files:
92 yield Path(current) / "SKILL.md"
93
94
95def tokenize(text: str) -> set[str]:
96 tokens = {
97 token for token in re.findall(r"[a-z][a-z0-9-]{2,}", text.lower())
98 if token not in STOPWORDS
99 }
100 for run in re.findall(r"[\u3400-\u9fff]{2,}", text):
101 if len(run) <= 8 and run not in STOPWORDS:
102 tokens.add(run)
103 tokens.update(run[i:i + 2] for i in range(len(run) - 1) if run[i:i + 2] not in STOPWORDS)
104 return set(sorted(tokens)[:120])
105
106
107def scan_indicators(skill_dir: Path) -> list[dict[str, object]]:
108 findings: dict[tuple[str, str], int] = {}
109 candidates = [skill_dir / "SKILL.md"]
110 for current, dirs, files in os.walk(skill_dir, followlinks=False):
111 dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
112 for filename in files:
113 path = Path(current) / filename
114 if path == skill_dir / "SKILL.md":
115 continue
116 if path.suffix.lower() in SCRIPT_SUFFIXES or filename in {"package.json", "pyproject.toml"}:
117 candidates.append(path)
118
119 for path in sorted(set(candidates))[:250]:
120 try:
121 if path.is_symlink() or path.stat().st_size > 1_000_000:
122 continue
123 text = path.read_text(encoding="utf-8", errors="replace")
124 except OSError:
125 continue
126 relative = str(path.relative_to(skill_dir))
127 for label, pattern in RISK_PATTERNS.items():
128 count = len(pattern.findall(text))
129 if count:
130 findings[(label, relative)] = count
131
132 return [
133 {"indicator": label, "file": filename, "matches": count}
134 for (label, filename), count in sorted(findings.items())
135 ]
136
137
138def skill_record(skill_file: Path, root: Path) -> dict[str, object]:
139 try:
140 text = skill_file.read_text(encoding="utf-8", errors="replace")
141 except OSError as exc:
142 return {"path": str(skill_file.parent), "root": str(root), "issues": [f"read error: {exc}"]}
143
144 data, issues = parse_frontmatter(text[:300_000])
145 name = data.get("name", "")
146 description = data.get("description", "")
147 if name and skill_file.parent.name != name:
148 issues.append(f"directory name '{skill_file.parent.name}' differs from skill name '{name}'")
149 return {
150 "name": name,
151 "description": description,
152 "path": str(skill_file.parent),
153 "root": str(root),
154 "trigger_tokens": sorted(tokenize(f"{name} {description}")),
155 "risk_indicators": scan_indicators(skill_file.parent),
156 "issues": issues,
157 }
158
159
160def find_overlaps(skills: list[dict[str, object]], threshold: float, limit: int) -> list[dict[str, object]]:
161 overlaps: list[dict[str, object]] = []
162 for i, left in enumerate(skills):
163 left_tokens = set(left.get("trigger_tokens", []))
164 if not left_tokens:
165 continue
166 for right in skills[i + 1:]:
167 right_tokens = set(right.get("trigger_tokens", []))
168 shared = left_tokens & right_tokens
169 union = left_tokens | right_tokens
170 if len(shared) < 3 or not union:
171 continue
172 score = len(shared) / len(union)
173 if score >= threshold:
174 overlaps.append({
175 "left": left.get("name") or left.get("path"),
176 "right": right.get("name") or right.get("path"),
177 "score": round(score, 3),
178 "shared_terms": sorted(shared)[:20],
179 })
180 overlaps.sort(key=lambda item: (-float(item["score"]), str(item["left"]), str(item["right"])))
181 return overlaps[:limit]
182
183
184def render_markdown(report: dict[str, object]) -> str:
185 summary = report["summary"]
186 lines = [
187 "# Skill inventory",
188 "",
189 f"- Skills found: {summary['skills_found']}",
190 f"- Duplicate names: {summary['duplicate_names']}",
191 f"- Trigger overlaps reported: {summary['trigger_overlaps']}",
192 f"- Skills with indicators: {summary['skills_with_risk_indicators']}",
193 "",
194 "| Skill | Root | Issues | Indicators |",
195 "|---|---|---:|---:|",
196 ]
197 for skill in report.get("skills", []):
198 lines.append(
199 f"| {skill.get('name') or '(invalid)'} | {skill.get('root')} | "
200 f"{len(skill.get('issues', []))} | {len(skill.get('risk_indicators', []))} |"
201 )
202 if report["duplicates"]:
203 lines.extend(["", "## Duplicate names", "", "```json", json.dumps(report["duplicates"], ensure_ascii=False, indent=2), "```"])
204 if report["overlaps"]:
205 lines.extend(["", "## Trigger overlaps", "", "```json", json.dumps(report["overlaps"], ensure_ascii=False, indent=2), "```"])
206 lines.extend(["", "> Indicators require manual review; they are not a malware verdict."])
207 return "\n".join(lines)
208
209
210def main() -> int:
211 parser = argparse.ArgumentParser(description=__doc__)
212 parser.add_argument("--root", action="append", required=True, help="Skill root; repeat for multiple roots")
213 parser.add_argument("--format", choices=("json", "markdown"), default="json")
214 parser.add_argument("--overlap-threshold", type=float, default=0.28)
215 parser.add_argument("--max-overlaps", type=int, default=200)
216 parser.add_argument("--summary-only", action="store_true", help="Omit per-skill records from output")
217 args = parser.parse_args()
218
219 roots: list[Path] = []
220 missing_roots: list[str] = []
221 for raw in args.root:
222 root = Path(os.path.expandvars(os.path.expanduser(raw))).resolve()
223 if root.is_dir():
224 roots.append(root)
225 else:
226 missing_roots.append(str(root))
227
228 records: list[dict[str, object]] = []
229 seen_paths: set[Path] = set()
230 for root in roots:
231 for skill_file in iter_skill_files(root):
232 resolved = skill_file.resolve()
233 if resolved in seen_paths:
234 continue
235 seen_paths.add(resolved)
236 records.append(skill_record(skill_file, root))
237 records.sort(key=lambda item: (str(item.get("name", "")), str(item.get("path", ""))))
238
239 by_name: dict[str, list[str]] = {}
240 for record in records:
241 name = str(record.get("name", ""))
242 if name:
243 by_name.setdefault(name, []).append(str(record["path"]))
244 duplicates = {name: paths for name, paths in sorted(by_name.items()) if len(paths) > 1}
245 overlaps = find_overlaps(records, args.overlap_threshold, args.max_overlaps)
246
247 report: dict[str, object] = {
248 "roots": [str(root) for root in roots],
249 "missing_roots": missing_roots,
250 "summary": {
251 "skills_found": len(records),
252 "duplicate_names": len(duplicates),
253 "trigger_overlaps": len(overlaps),
254 "skills_with_risk_indicators": sum(bool(r.get("risk_indicators")) for r in records),
255 },
256 "duplicates": duplicates,
257 "overlaps": overlaps,
258 "skills": records,
259 "notice": "Risk indicators and trigger overlap require manual review; they are not verdicts.",
260 }
261 if args.summary_only:
262 report.pop("skills")
263 if args.format == "markdown":
264 print(render_markdown(report))
265 else:
266 print(json.dumps(report, ensure_ascii=False, indent=2))
267 return 0
268
269
270if __name__ == "__main__":
271 raise SystemExit(main())