Archive safety
How SkillHub treats every skill tarball as hostile — what the packaging gate rejects, the size and shape limits, and the stable rejection codes.
A skill travels as one deterministic tar.gz. That tarball is the artifact: it
is what the registry stores, what an installer downloads, and what an attacker
would aim at — a ../ path that writes outside the package, a symlink pointed at
your SSH key, a few hundred kilobytes that inflate into a few gigabytes.
SkillHub treats every archive as hostile input. The registry runs the checks below on every upload, the CLI runs them again on every tarball it downloads, and the packer refuses to produce an archive that would fail the path rules in the first place.
The guarantee
- Nothing is extracted until the whole archive passes. Unpacking is an in-memory operation that returns the files as bytes; only then does the CLI write them, into a fresh staging directory that is renamed into place. Not one byte from inside a rejected archive reaches your filesystem.
- Every rejection carries a stable code. The code is machine-readable and fixed; the human-readable message alongside it is free to change. Branch on the code.
- Size limits bind during decompression, not after. The compressed size is checked before a single byte is inflated, and the inflater itself is capped at the smaller of the total-size limit and the ratio limit — so a decompression bomb aborts mid-stream and is never held in memory in full.
- Path rules run on the effective path. A PAX
pathoverride goes through exactly the same checks as a raw header name, and the checks are segment-wise, so traversal that is only visible after normalization (a/b/../../../x) is caught too. - Only regular files and directories are accepted. A symlink is rejected even when its target sits inside the package — there is no in-tree exception. Directory entries are validated and counted, but never extracted; only regular files come out.
- The registry re-verifies what it stored. Before unpacking an upload it re-hashes the stored object and compares it against the sha256 the publisher committed to when the publish started. A mismatch is rejected, not unpacked.
What gets rejected
| Class | What it catches | Codes |
|---|---|---|
| Traversal and absolute paths | A .. segment anywhere in a path, a leading /, a Windows drive letter or UNC path, a backslash separator, an empty or . segment, a path over 1024 bytes, and control, zero-width, or bidi-override characters in a name (the trick that renders evil<RLO>gpj.sh as evilhs.jpg). | path-traversal, path-absolute, path-backslash, path-malformed, unsafe-unicode |
| Links and special entries | Symlinks and hardlinks, character and block devices, FIFOs, contiguous files, and any entry type outside regular file and directory. | symlink, hardlink, special-entry |
| Decompression and size limits | A compressed archive over the upload limit, decompressed output over the total limit or over the expansion ratio, more entries than the count limit, and nesting past the depth limit. | gz-too-large, unpacked-too-large, ratio-exceeded, too-many-entries, too-deep |
| Structural malformation | Bytes that are not gzip or not tar, a bad header checksum, a missing ustar magic, a truncated entry, a missing end-of-archive marker, non-zero bytes after that marker (a second gzip member smuggling entries past a naive reader), an archive with no entries, duplicate paths after case and Unicode folding — including a file that collides with a directory — and the GNU and PAX extensions that can rewrite an entry's path, size, or link target. | not-gzip, not-tar, empty-archive, trailing-garbage, duplicate-path, gnu-extension, pax-global, pax-unsafe |
The limits
| Limit | Value |
|---|---|
| Compressed tarball | 10 MiB |
| Total unpacked size | 50 MiB |
| Entries (files plus directories) | 400 |
| Directory nesting depth | 6 |
| Expansion ratio | 100× the compressed size |
Depth counts the directories above a file: a file at the package root is depth
0, and a/b/c/notes.md is depth 3.
The two limits you can acknowledge
Two of those limits describe a package's shape, not its safety: nesting depth
(too-deep) and entry count (too-many-entries). A skill that trips one is
oversized, not hostile, so a publisher can acknowledge it and ship:
skillhub publish --allow-warningsEverything else always hard-fails. Path safety, links and special entries, the
PAX and GNU rules, duplicates, malformed archives, and the three
decompression-bomb limits reject the archive no matter which flags are set —
--allow-warnings cannot reach them, and neither can the admin-only --force,
which overrides the security scan and nothing else.
Acknowledged warnings are recorded on the published version and come back in
published[].warnings, so a package that shipped over a shape limit says so
permanently.
Reading a rejection
Where a code surfaces depends on which side hit it:
- Publishing. The CLI checks your directory before it packs anything, so most
problems fail locally with exit
1and a finding you can act on. An archive the registry rejects comes back as aVALIDATIONerror whose issue is ontarball, with the stable code leading the message. - Installing. A downloaded tarball that fails the gate exits
4with codeBAD_ARCHIVE, and--jsoncarries the exact rule inerror.details.archiveErrorCode.
See Common errors for the publisher-facing codes and what to do about each.