fix(book-to-skill): guard EPUB XML, cap zip expansion, refuse symlinked trees

Addresses the second automated review on PR #941. All three code findings
verified against the actual behaviour, not just patched.

[Medium] shutil.copytree dereferenced symlinks the validator never saw. The
validator checks SKILL.md, the three supporting files and chapters/*.md; the
copy then followed a link anywhere else in the tree (assets/, any subdirectory)
and baked the target's real content into a package that can go out as
--distribution shareable. _assert_no_symlinks() now walks the whole tree and
refuses, and runs BEFORE the validation branch so --skip-validation cannot
bypass it. copytree also passes symlinks=True so loosening that check later
cannot silently reintroduce dereferencing.
Verified: a symlink in assets/ pointing at a secret file is refused both with
and without --skip-validation, the secret never lands in a package, and a clean
tree still emits.

[Medium] The DOCX XXE/entity guard did not extend to EPUB's ebooklib path.
Upstream hardened DOCX only. EPUB is the same zip-of-XML shape and ebooklib —
one of the packages this skill recommends installing — parsed container.xml,
the OPF and content docs with no equivalent pre-check. The guard moved to a new
book_to_skill/zip_safety.py and now runs for both formats.
Verified: an EPUB whose OPF declares an entity is refused; a clean EPUB still
extracts and detects its chapter.

[Low] No size cap before decompressing zip members. Every archive read now goes
through safe_read(), which checks the declared uncompressed size and the
compression ratio against the central directory before decompressing, and
charges actual bytes against a per-archive budget so a lying directory cannot
get past it either.
Verified: a 200 MB / 1029x bomb is refused at ~14 MB peak RSS instead of being
materialized.

[Low] The PR body's "12 numbered items" was stale against README's list. Fixed
in the PR description; the in-repo count is synced to 18 across plugin.json,
CLAUDE.md and CHANGELOG.

Recorded as deviations 17 and 18. Counters: tools 662 -> 663 (zip_safety.py);
that module is allowlisted in smoke_exceptions.txt like its siblings.

All gates green: compileall, check_paths --all, check_dual_publish,
smoke_scripts (0 failed), derive_counters --check, check_plugin_json --all
(0 FAIL). Security auditor unchanged at 0 critical / 4 high.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zu9Gmm9S78c2t3kDLnpPX
This commit is contained in:
Claude 2026-08-05 07:31:04 +00:00
parent b5031e976e
commit 9ed709aef3
No known key found for this signature in database
11 changed files with 231 additions and 37 deletions

View file

@ -8,7 +8,7 @@
"homepage": "https://github.com/alirezarezvani/claude-skills",
"repository": "https://github.com/alirezarezvani/claude-skills",
"metadata": {
"description": "363 production-ready skills across 18 domains (engineering, engineering-core, marketing, product, c-level, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 662 Python tools, 746 reference guides, 103 agents (cs-* + personas), 118 slash commands across 89 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
"description": "363 production-ready skills across 18 domains (engineering, engineering-core, marketing, product, c-level, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 663 Python tools, 746 reference guides, 103 agents (cs-* + personas), 118 slash commands across 89 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
"version": "2.11.2"
},
"plugins": [

View file

@ -22,7 +22,7 @@ parsers) is vendored close to verbatim and keeps upstream's format chains, chapt
detection across Latin/Roman/Chinese/Thai/Korean heading styles, invisible-Unicode
(Trojan Source) sanitization, and the DOCX entity-expansion guard.
**16 numbered deviations** are recorded in `engineering/book-to-skill/README.md`,
**18 numbered deviations** are recorded in `engineering/book-to-skill/README.md`,
which is the authoritative list. Highlights:
- **(5) No implicit installs.** `--install-missing` defaults to `report` — it prints
@ -48,6 +48,16 @@ which is the authoritative list. Highlights:
`--workdir` is symlink-refused and mode-restricted. Also fixes a real bug: `parsers/calibre.py`
read a module-level path constant and so ignored `--workdir` entirely.
- **(17) Zip-of-XML hardening generalized, plus decompression-bomb caps.** Upstream's
DTD/entity guard covered DOCX only; EPUB's `ebooklib` path handed the archive straight to a
third-party XML stack. The guard now lives in `book_to_skill/zip_safety.py` and runs for
both, and every archive read checks declared size and compression ratio before
decompressing — a 200 MB zip bomb is refused at ~14 MB peak RSS.
- **(18) Packaging refuses a source tree containing symlinks.** `shutil.copytree` defaults to
following links, which would bake a link target's real content into a package that may be
emitted as `--distribution shareable`. `_assert_no_symlinks()` walks the whole tree and
refuses, before the validation branch so `--skip-validation` cannot bypass it.
**Repo-native addition with no upstream counterpart — Step 11 / `/cs:book-to-plugin`.**
Upstream stops at a bare folder in `~/.claude/skills/`, which this library cannot route
to. `skill_plugin_emitter.py` wraps a compiled skill as a full plugin package (manifest
@ -73,7 +83,7 @@ Regenerated `engineering/agent-harness/.../assets/harnesses/engineering.json`. P
### Changed — counters
skills 362 → 363, tools 644 → 662, refs 741 → 746, agents 102 → 103, commands
skills 362 → 363, tools 644 → 663, refs 741 → 746, agents 102 → 103, commands
116 → 118, plugins 88 → 89 (derived via `scripts/derive_counters.py --check`).
---

File diff suppressed because one or more lines are too long

View file

@ -26,10 +26,10 @@ The most comprehensive open-source library of Claude Code skills and agent plugi
Claude Code skills (also called agent skills or coding agent plugins) are modular instruction packages that give AI coding agents domain expertise they don't have out of the box. Each skill includes:
- **SKILL.md** — structured instructions, workflows, and decision frameworks
- **Python tools** — 662 CLI scripts (all stdlib-only, zero pip installs)
- **Python tools** — 663 CLI scripts (all stdlib-only, zero pip installs)
- **Reference docs** — 746 templates, checklists, and domain-specific knowledge files
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 662 Python tools run anywhere Python runs.
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 663 Python tools run anywhere Python runs.
### Skills vs Agents vs Personas
@ -354,7 +354,7 @@ Yes. Skills work natively with 13 tools: Claude Code, OpenAI Codex, Gemini CLI,
No. We follow semantic versioning and maintain backward compatibility within patch releases. Existing script arguments, plugin source paths, and SKILL.md structures are never changed in patch versions. See the [CHANGELOG](CHANGELOG.md) for details on each release.
**Are the Python tools dependency-free?**
Yes. All 662 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
Yes. All 663 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
**How do I create my own Claude Code skill?**
Each skill is a folder with a `SKILL.md` (frontmatter + instructions), optional `scripts/`, `references/`, and `assets/`. See the [Skills & Agents Factory](https://github.com/alirezarezvani/claude-code-skills-agents-factory) for a step-by-step guide.

View file

@ -16,6 +16,6 @@
"derived_from": "https://github.com/virgiliojr94/book-to-skill",
"original_author": "virgiliojr94",
"original_license": "MIT",
"derivation_note": "The extraction library (book_to_skill/ — config, exceptions, sanitize, dependencies, utils, and the 7 per-format parsers) is vendored from upstream and carries upstream's multi-format chains, chapter detection across 5 script families, and Unicode/XXE hardening. 16 numbered deviations are recorded in README.md, which is the authoritative list: the workflow was rewritten Claude-Code-first for this repo's conventions, the 3 upstream tools were reworked into 4 argparse CLIs with --help/--sample, install-on-import behaviour was replaced with a report-only default, and Step 11 (plugin emission with a rights gate) plus the validator's budget and index families are additions with no upstream counterpart."
"derivation_note": "The extraction library (book_to_skill/ — config, exceptions, sanitize, dependencies, utils, and the 7 per-format parsers) is vendored from upstream and carries upstream's multi-format chains, chapter detection across 5 script families, and Unicode/XXE hardening. 18 numbered deviations are recorded in README.md, which is the authoritative list: the workflow was rewritten Claude-Code-first for this repo's conventions, the 3 upstream tools were reworked into 4 argparse CLIs with --help/--sample, install-on-import behaviour was replaced with a report-only default, and Step 11 (plugin emission with a rights gate) plus the validator's budget and index families are additions with no upstream counterpart."
}
}

View file

@ -210,6 +210,26 @@ small; just read it."
(`SKILL_FILE_BUDGETS`, `CHAPTER_TOKEN_CEILING`) rather than being restated in each tool,
where they would drift the first time a cap changed.
17. **Zip-of-XML hardening generalized to EPUB, plus decompression-bomb caps.** Upstream
hardened DOCX and only DOCX: `validate_docx_xml_safety()` screened that archive for
DTD/entity declarations before any parser touched it. EPUB is the same shape — a zip whose
members are XML — and its `ebooklib` path handed the file straight to a third-party XML
stack with no equivalent check, despite `ebooklib` being one of the packages this skill
recommends installing. The guard now lives in `book_to_skill/zip_safety.py` and runs for
both formats. Every archive read also goes through `safe_read()`, which consults the
declared uncompressed size and the compression ratio *before* decompressing: a 200 MB
zip bomb is refused at ~14 MB peak RSS instead of being materialized. Neither parser ever
writes archive members to disk, so zip-slip stays out of scope by construction.
18. **Packaging refuses a source tree containing symlinks.** The validator checks the files it
knows about (`SKILL.md`, the three supporting files, `chapters/*.md`), but `shutil.copytree`
defaults to `symlinks=False` and follows a link *anywhere else* in the tree — an `assets/`
entry, any subdirectory — baking the target's real content into a package that may then be
emitted as `--distribution shareable`. `_assert_no_symlinks()` now walks the whole tree and
refuses, and it runs **before** the validation branch so `--skip-validation` cannot bypass
it. `copytree` also passes `symlinks=True` so a future edit loosening that check cannot
silently reintroduce dereferencing.
---
## Security audit

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import zipfile
import sys
from book_to_skill.exceptions import ExtractionError
from book_to_skill.zip_safety import safe_read, validate_zip_xml_safety
def extract_docx_with_python_docx(docx_path: str) -> str | None:
@ -28,7 +29,7 @@ def extract_docx_with_zipfile(docx_path: str) -> str | None:
import xml.etree.ElementTree as ET
with zipfile.ZipFile(docx_path) as zf:
xml_bytes = zf.read("word/document.xml")
xml_bytes = safe_read(zf, "word/document.xml")
root = ET.fromstring(xml_bytes)
ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
parts: list[str] = []
@ -69,27 +70,13 @@ def extract_docx_with_zipfile(docx_path: str) -> str | None:
def validate_docx_xml_safety(docx_path: str) -> None:
"""Scan all XML files in the DOCX zip archive to prevent XML Entity Expansion (Billion Laughs) and XXE injections."""
try:
with zipfile.ZipFile(docx_path) as zf:
for name in zf.namelist():
if name.endswith(".xml") or name.endswith(".rels"):
xml_bytes = zf.read(name)
for encoding in ("utf-8", "utf-16", "utf-16le", "utf-16be", "utf-32"):
try:
content = xml_bytes.decode(encoding, errors="ignore").upper()
except LookupError:
continue
if "<!DOCTYPE" in content or "<!ENTITY" in content:
raise ExtractionError(
f"Security validation failed: XML file '{name}' in DOCX archive contains forbidden DTD or entity declarations."
)
except zipfile.BadZipFile as e:
raise ExtractionError(f"Invalid DOCX file: {e}")
except ExtractionError:
raise
except Exception as e:
raise ExtractionError(f"Error during security validation of DOCX archive: {e}")
"""Refuse a DOCX whose XML declares a DTD or entity (billion-laughs / XXE).
Now a thin wrapper over the shared zip guard so EPUB gets identical
treatment; the scan also enforces per-member size and compression-ratio caps
before decompressing anything.
"""
validate_zip_xml_safety(docx_path, label="DOCX")
def extract_docx(docx_path: str) -> tuple[str, str]:

View file

@ -5,9 +5,19 @@ import re
import zipfile
import sys
from book_to_skill.parsers.html_text import _HTMLTextExtractor
from book_to_skill.zip_safety import _Budget, safe_read, validate_zip_xml_safety
def extract_with_ebooklib(epub_path: str) -> str | None:
"""Extract via ebooklib, after the same XML guard DOCX gets.
ebooklib parses container.xml, the OPF package document and the content docs
with a third-party XML stack whose entity handling this code does not
control. The archive is an untrusted file the user was handed, so it is
screened for DTD/entity declarations before ebooklib sees it upstream ran
this check on DOCX only.
"""
validate_zip_xml_safety(epub_path, label="EPUB")
try:
import ebooklib
from ebooklib import epub
@ -34,7 +44,7 @@ def _find_opf_path(zf: zipfile.ZipFile) -> str | None:
"""
# Spec-defined: read container.xml for the rootfile path
try:
container = zf.read("META-INF/container.xml").decode("utf-8", errors="replace")
container = safe_read(zf, "META-INF/container.xml").decode("utf-8", errors="replace")
match = re.search(r'full-path=["\']([^"\']+\.opf)["\']', container)
if match:
return match.group(1)
@ -50,6 +60,7 @@ def extract_with_zipfile(epub_path: str) -> str | None:
"""stdlib-only EPUB extractor: unzip → parse HTML files."""
try:
with zipfile.ZipFile(epub_path) as zf:
budget = _Budget()
names = zf.namelist()
# Locate OPF and determine its directory for resolving relative hrefs
@ -61,7 +72,7 @@ def extract_with_zipfile(epub_path: str) -> str | None:
spine_order: list[str] = []
seen: set[str] = set()
if opf_path:
opf_text = zf.read(opf_path).decode("utf-8", errors="replace")
opf_text = safe_read(zf, opf_path, budget).decode("utf-8", errors="replace")
# Manifest: item id -> resolved href. Parse each <item> opening
# tag so attribute order (id before/after href) does not matter;
@ -99,7 +110,7 @@ def extract_with_zipfile(epub_path: str) -> str | None:
parts = []
for name in html_files:
try:
raw = zf.read(name).decode("utf-8", errors="replace")
raw = safe_read(zf, name, budget).decode("utf-8", errors="replace")
parser = _HTMLTextExtractor()
parser.feed(raw)
parts.append(parser.get_text())
@ -118,7 +129,7 @@ def count_epub_chapters(epub_path: str) -> int:
opf_path = _find_opf_path(zf)
if not opf_path:
return 0
opf_text = zf.read(opf_path).decode("utf-8", errors="replace")
opf_text = safe_read(zf, opf_path).decode("utf-8", errors="replace")
return len(re.findall(r'<itemref\b', opf_text))
except Exception:
return 0

View file

@ -0,0 +1,130 @@
"""Shared safety checks for zip-of-XML container formats (DOCX, EPUB).
Upstream hardened DOCX and only DOCX: `validate_docx_xml_safety()` scanned that
archive for DTD/entity declarations before any parser touched it. EPUB is the
same shape a zip whose members are XML (`container.xml`, the OPF package
document, NCX, content docs) and its `ebooklib` path handed the file straight
to an XML parser with no equivalent pre-check, even though `ebooklib` is one of
the packages this skill recommends installing "for best results".
Two classes of attack, both from a file the user was handed rather than wrote:
1. **Entity expansion / XXE.** `<!ENTITY>` declarations drive billion-laughs
memory exhaustion; external entities read local files into the parsed output.
Python's `xml.etree.ElementTree` does not resolve *external* entities, but it
does expand internal ones and third-party parsers built on lxml vary. The
check refuses the archive rather than trusting each parser's defaults.
2. **Decompression bombs.** A few KB of zip can expand to gigabytes. Every read
goes through `safe_read()`, which consults the declared uncompressed size and
the compression ratio *before* decompressing, so a bomb is refused rather
than materialized.
Neither parser ever writes archive members to disk (no `extractall`/`extract`),
so zip-slip is out of scope by construction.
"""
from __future__ import annotations
import zipfile
from book_to_skill.exceptions import ExtractionError
# A single member of a book archive. Generous for a chapter's XHTML or a
# document.xml, far below what a bomb needs to hurt.
MAX_MEMBER_BYTES = 64 * 1024 * 1024
# Everything read from one archive, across all members.
MAX_TOTAL_BYTES = 512 * 1024 * 1024
# Declared-uncompressed / stored ratio. Real prose and XML land well under 100x;
# the classic zip bomb is ~1000x and up.
MAX_COMPRESSION_RATIO = 200
# Members worth scanning for DTD/entity declarations: XML by extension, plus the
# extensionless `mimetype` member and OPF/NCX which are XML without an .xml suffix.
_XML_SUFFIXES = (".xml", ".rels", ".opf", ".ncx", ".xhtml", ".html", ".htm")
class _Budget:
"""Running total of bytes decompressed from one archive."""
def __init__(self, limit: int = MAX_TOTAL_BYTES) -> None:
self.limit = limit
self.used = 0
def charge(self, size: int, name: str) -> None:
self.used += size
if self.used > self.limit:
raise ExtractionError(
f"archive exceeds the {self.limit:,}-byte decompression budget "
f"(reading '{name}') — refusing to continue"
)
def safe_read(zf: zipfile.ZipFile, name: str, budget: _Budget | None = None) -> bytes:
"""Read one archive member after checking its declared size and ratio.
The checks run against the zip's central directory *before* decompressing,
so a bomb never gets materialized. A liar in the directory still cannot get
past the budget, because the actual read is charged against it too.
"""
try:
info = zf.getinfo(name)
except KeyError as exc:
raise ExtractionError(f"archive member not found: {name}") from exc
if info.file_size > MAX_MEMBER_BYTES:
raise ExtractionError(
f"archive member '{name}' declares {info.file_size:,} bytes uncompressed; "
f"the per-member limit is {MAX_MEMBER_BYTES:,} bytes"
)
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > MAX_COMPRESSION_RATIO:
raise ExtractionError(
f"archive member '{name}' expands {ratio:.0f}x "
f"({info.compress_size:,} -> {info.file_size:,} bytes); the limit is "
f"{MAX_COMPRESSION_RATIO}x — this looks like a decompression bomb"
)
data = zf.read(name)
if budget is not None:
budget.charge(len(data), name)
return data
def is_xml_member(name: str) -> bool:
return name.lower().endswith(_XML_SUFFIXES)
def validate_zip_xml_safety(archive_path: str, label: str = "archive") -> None:
"""Refuse a zip-of-XML archive that declares a DTD or any entity.
Scans every XML-ish member across the encodings a hostile file might use to
hide the declaration from a naive UTF-8 substring search. Generalized from
upstream's DOCX-only guard so EPUB gets the same treatment.
"""
try:
with zipfile.ZipFile(archive_path) as zf:
budget = _Budget()
for name in zf.namelist():
if not is_xml_member(name):
continue
xml_bytes = safe_read(zf, name, budget)
for encoding in ("utf-8", "utf-16", "utf-16le", "utf-16be", "utf-32"):
try:
content = xml_bytes.decode(encoding, errors="ignore").upper()
except LookupError:
continue
if "<!DOCTYPE" in content or "<!ENTITY" in content:
raise ExtractionError(
f"security validation failed: XML member '{name}' in the {label} "
f"archive contains a forbidden DTD or entity declaration"
)
except zipfile.BadZipFile as exc:
raise ExtractionError(f"invalid {label} file: {exc}") from exc
except ExtractionError:
raise
except Exception as exc:
raise ExtractionError(
f"error during security validation of the {label} archive: {exc}"
) from exc

View file

@ -43,6 +43,7 @@ from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sys
@ -136,6 +137,34 @@ def _assert_replaceable(package_root: Path, dest_root: Path, skill_dir: Path) ->
)
def _assert_no_symlinks(skill_dir: Path) -> None:
"""Refuse to package a source tree containing any symbolic link.
The validator checks the files it knows about SKILL.md, the three
supporting files, chapters/*.md but `shutil.copytree` defaults to
`symlinks=False`, which follows a link *anywhere else* in the tree (an
`assets/` entry, an arbitrary subdirectory) and bakes the target's real
content into the emitted package. That package can then go out as
`--distribution shareable`, so a link pointing at something outside the
skill becomes part of a published artifact.
Refusing beats `copytree(symlinks=True)`: preserving the link would ship a
package whose contents depend on the emitting machine's filesystem. This
check runs even under `--skip-validation`, which otherwise disables the
file-level symlink checks entirely.
"""
offenders = [p for p in sorted(skill_dir.rglob("*")) if p.is_symlink()]
if offenders:
listed = "\n".join(f" {p.relative_to(skill_dir).as_posix()} -> {os.readlink(p)}"
for p in offenders[:10])
more = f"\n ... and {len(offenders) - 10} more" if len(offenders) > 10 else ""
raise EmitError(
f"source skill contains {len(offenders)} symbolic link(s); packaging would copy "
f"the target's content into a distributable package:\n{listed}{more}\n"
"Replace them with real files, or point --skill-dir at a tree without links."
)
def _plugin_manifest(identity: dict, *, domain: str, author: str, author_url: str,
repository: str, distribution: str, rights: str | None,
source_note: str) -> dict:
@ -337,6 +366,10 @@ def emit(*, skill_dir: Path, dest_root: Path, domain: str, author: str, author_u
"Or emit with --distribution local (the default) to keep it on this machine."
)
# Unconditional: --skip-validation waives content findings, never the
# guarantee that packaging copies only what is actually in the source tree.
_assert_no_symlinks(skill_dir)
if not skip_validation:
try:
findings = validate(skill_dir)
@ -389,7 +422,9 @@ def emit(*, skill_dir: Path, dest_root: Path, domain: str, author: str, author_u
target = package_root / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
shutil.copytree(skill_dir, package_root / "skills" / identity["slug"])
# symlinks=True is redundant after _assert_no_symlinks, and kept so a future
# edit that loosens that check cannot silently reintroduce dereferencing.
shutil.copytree(skill_dir, package_root / "skills" / identity["slug"], symlinks=True)
return result

View file

@ -83,6 +83,7 @@ engineering/skillopt-sleep/skillopt_sleep/types.py # internal module; module it
engineering/book-to-skill/skills/book-to-skill/scripts/book_to_skill/__init__.py # package init, not a CLI
engineering/book-to-skill/skills/book-to-skill/scripts/book_to_skill/dependencies.py # internal module
engineering/book-to-skill/skills/book-to-skill/scripts/book_to_skill/utils.py # internal module
engineering/book-to-skill/skills/book-to-skill/scripts/book_to_skill/zip_safety.py # internal module
engineering/book-to-skill/skills/book-to-skill/scripts/book_to_skill/parsers/calibre.py # internal module
engineering/book-to-skill/skills/book-to-skill/scripts/book_to_skill/parsers/docx.py # internal module
engineering/book-to-skill/skills/book-to-skill/scripts/book_to_skill/parsers/epub.py # internal module