From 2233da2c6a4da5a886d91341678b4502b75a13cc Mon Sep 17 00:00:00 2001 From: Joe Qiu <39745128+josephmqiu@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:04:19 +0800 Subject: [PATCH] Replace raw 'rm -rf exes/{slug}' with validated delete action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /delete-ex flow had the agent execute rm -rf with a verbatim user-supplied slug — '/delete-ex ../..' would escape the exes directory (with a global install, that's inside the user's skills directory), and any templating mistake by the model becomes an irreversible deletion. Adds 'skill_writer.py --action delete' with three layers of defense: - slug whitelist validation ([a-z0-9][a-z0-9_-]{0,63}) - resolved-path containment check against base-dir - refusal to delete directories lacking meta.json SKILL.md (zh + en) now instructs showing the resolved path and name for explicit confirmation, then calling the tool — never raw rm -rf. Fixes #27 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Qxv8G25EYkYzQyY6FdJ6qH --- SKILL.md | 10 ++++---- tools/skill_writer.py | 54 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/SKILL.md b/SKILL.md index fc1755f..6edb7d3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -348,10 +348,11 @@ python3 ${CLAUDE_SKILL_DIR}/tools/version_manager.py --action rollback --slug {s ``` `/delete-ex {slug}`: -确认后执行: +先展示将被删除的完整路径和 meta.json 中的 name,用户明确确认后执行: ```bash -rm -rf exes/{slug} +python3 ${CLAUDE_SKILL_DIR}/tools/skill_writer.py --action delete --slug {slug} --base-dir ./exes ``` +工具内部会校验 slug 格式、确认目标位于 base-dir 之内且确实是一个前任 Skill 目录。**不要直接拼接 `rm -rf` 命令。** --- --- @@ -617,7 +618,8 @@ python3 ${CLAUDE_SKILL_DIR}/tools/version_manager.py --action rollback --slug {s ``` `/delete-ex {slug}`: -After confirmation: +First show the full path to be deleted and the name from meta.json, then after explicit user confirmation: ```bash -rm -rf exes/{slug} +python3 ${CLAUDE_SKILL_DIR}/tools/skill_writer.py --action delete --slug {slug} --base-dir ./exes ``` +The tool validates the slug format, confirms the target resolves inside base-dir, and checks it is actually an ex Skill directory. **Never construct raw `rm -rf` commands.** diff --git a/tools/skill_writer.py b/tools/skill_writer.py index 6bcad94..91d775b 100644 --- a/tools/skill_writer.py +++ b/tools/skill_writer.py @@ -15,10 +15,13 @@ --base-dir ./exes python3 skill_writer.py --action list --base-dir ./exes + + python3 skill_writer.py --action delete --slug xiaomei --base-dir ./exes """ from __future__ import annotations +import re import json import shutil import argparse @@ -301,9 +304,46 @@ def list_exes(base_dir: Path) -> list: return exes +SLUG_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}") + + +def validate_slug(slug: str) -> str: + """校验 slug 格式,防止路径穿越等非法输入""" + if not SLUG_RE.fullmatch(slug): + raise ValueError( + f"非法 slug:{slug!r}(只允许小写字母/数字/下划线/连字符,且以字母或数字开头)" + ) + return slug + + +def delete_skill(base_dir: Path, slug: str) -> Path: + """ + 安全删除一个前任 Skill 目录。 + 三重防御:校验 slug 格式;确认解析后的目标位于 base_dir 之内; + 确认目标确实是一个前任 Skill 目录(含 meta.json)。 + """ + validate_slug(slug) + + base_resolved = base_dir.resolve() + skill_dir = (base_dir / slug).resolve() + + # 双重保险:即使 slug 校验被绕过,也绝不删除 base_dir 之外的路径 + if base_resolved not in skill_dir.parents: + raise ValueError(f"拒绝删除 base-dir 之外的路径:{skill_dir}") + + if not skill_dir.exists(): + raise ValueError(f"找不到 Skill 目录:{skill_dir}") + + if not (skill_dir / "meta.json").exists(): + raise ValueError(f"{skill_dir} 不是一个前任 Skill 目录(缺少 meta.json),拒绝删除") + + shutil.rmtree(skill_dir) + return skill_dir + + def main() -> None: parser = argparse.ArgumentParser(description="Skill 文件写入器") - parser.add_argument("--action", required=True, choices=["create", "update", "list"]) + parser.add_argument("--action", required=True, choices=["create", "update", "list", "delete"]) parser.add_argument("--slug", help="前任 slug(用于目录名)") parser.add_argument("--name", help="前任昵称") parser.add_argument("--meta", help="meta.json 文件路径") @@ -373,6 +413,18 @@ def main() -> None: new_version = update_skill(skill_dir, memories_patch, persona_patch) print(f"✅ Skill 已更新到 {new_version}:{skill_dir}") + elif args.action == "delete": + if not args.slug: + print("错误:delete 操作需要 --slug", file=sys.stderr) + sys.exit(1) + + try: + deleted = delete_skill(base_dir, args.slug) + except ValueError as e: + print(f"错误:{e}", file=sys.stderr) + sys.exit(1) + print(f"✅ 已删除 {deleted}") + if __name__ == "__main__": main()