---
name: unzip-attachment
description: "Safely extract a zip archive the admin has uploaded, inventory its contents, and route each entry to the right downstream specialist, refusing path traversal, symlink escape, and decompression bombs. Use when an admin uploads a zip to unzip or extract."
---

# Unzip Attachment

Safely extract a zip archive the admin has uploaded, inventory its contents, and propose one concrete follow-up per entry class — refusing archives that attempt path traversal, symlink escape, or decompression-bomb pathologies.

**Invoked by the admin agent directly.** Admin owns all unzipping: the operator drops a zip, admin runs this skill against the attachment, then forwards the extracted tree to the appropriate downstream specialist — `specialists:content-producer` for static-site zips carrying a host-website / publish-site / put-online intent (it chains into `publish-site` on the extracted tree), and `specialists:librarian` for graph-bound archives (LinkedIn exports, conversation transcripts, loose PDFs, etc.). The skill itself does not dispatch; it extracts safely, inventories the result, and surfaces the post-unzip contents so admin can route.

## When to Use

Activate when the `[ATTACHMENTS:]` block of the current turn contains an entry whose MIME is `application/zip` or `application/x-zip-compressed`. Every such attachment line carries an `ID: <uuid>  Path: <storagePath>` pair — the `storagePath` is the absolute path to the `.zip` on disk and is the input to this flow.

Do **not** activate for agent-generated zips (those are already trusted — they originate from `storeGeneratedFile`). The MIME allow-list restricts this skill to admin-uploaded archives by construction: no other code path produces a `[ATTACHMENTS:]` line with a zip MIME.

The `[ATTACHMENTS:]` block is contractually present on every turn that originally had attachments, including post-`max-turns-interrupted` recovery retries. Do **not** add a filesystem fallback search — the resolved `Path:` line is authoritative.

## Invariants

These are not guidelines. Every one of them is mechanically enforced by the shell primitives below; if any check fails, abort the flow, emit the named log line, and tell the operator verbatim what tripped it.

- **No byte of the archive may land outside `{accountDir}/extracted/{attachmentId}/`.** The destination is fresh per upload. Extraction uses `unzip -oq` into that directory and only that directory.
- **Sum of declared uncompressed sizes must be ≤ `MAX_ZIP_UNCOMPRESSED_BYTES` (100 MiB, defined in [platform/ui/app/lib/attachments.ts](../../../../ui/app/lib/attachments.ts)).** Checked by summing column 4 of `unzip -Z` output *before* any write.
- **No entry may be a symlink.** Checked by parsing column 1 of `unzip -Z` output (permission column; leading `l` = symlink) before extraction; the post-extraction loop also runs `find <dest> -type l` as a ground-truth backstop.
- **No entry name may start with `../`.** Checked by parsing the name column of `unzip -Z` output before extraction; the post-extraction `realpath --relative-to <dest>` loop is a defense-in-depth backstop for `unzip` versions that silently sanitise `../` during extraction.
- **Password-protected archives are refused with a fixed message.** Detected by column 5 of `unzip -Z` output: a leading uppercase `T` or `B` (text/binary, encrypted) marks an encrypted entry. Never prompt for a key.

See [references/safety.md](references/safety.md) for the precise shell commands, attack examples, and refusal templates.

## Flow

1. **Resolve paths.** From the `[ATTACHMENTS:]` line read `attachmentId` and `storagePath`. Set `dest="${ACCOUNT_DIR}/extracted/${attachmentId}"`. Create it with `mkdir -p "$dest"`.
2. **Single-pass pre-flight.** One `unzip -Z "$storagePath"` invocation produces the zipinfo column listing; pipe through an awk parser that checks all four invariants in one read of the output. Non-zero exit from `unzip -Z` → corrupt archive, refuse with `unreadable reason=corrupt`. Otherwise the awk gates (in order: encrypted, symlink, zip-slip, oversize) fire the matching refusal log line and exit before extraction. Inline this single bash block — do **not** split into two or more `unzip` calls:
   ```sh
   ZINFO=$(unzip -Z "$storagePath" 2>&1) || { echo "[skill:unzip] unreadable attachmentId=$attachmentId reason=corrupt"; exit 1; }
   echo "$ZINFO" | awk -v aid="$attachmentId" -v lim=$((100*1024*1024)) '
     NR>2 && $1 ~ /^[-lrwxd?]/ {
       if ($5 ~ /^[TB]/)           { print "[skill:unzip] unreadable attachmentId=" aid " reason=password"; bad=1; exit }
       if ($1 ~ /^l/)              { print "[skill:unzip] symlink-blocked attachmentId=" aid " entry=" $9; bad=1; exit }
       for (i=9; i<=NF; i++) if ($i ~ /^\.\.\//) { print "[skill:unzip] zip-slip-blocked attachmentId=" aid " entry=" $i; bad=1; exit }
       sum += $4
     }
     END { if (!bad && sum > lim) { print "[skill:unzip] oversize attachmentId=" aid " uncompressed=" sum " limit=" lim; bad=1 }; exit bad }
   ' || exit 1
   DECLARED_BYTES=$(echo "$ZINFO" | awk 'NR>2 && $1 ~ /^[-lrwxd?]/ { s += $4 } END { print s+0 }')
   ```
3. **Emit the start log line** — `[skill:unzip] start attachmentId=<uuid> dest=<path> declaredBytes=<n> preflightPasses=1` — exactly once, before the extraction command. The `preflightPasses=1` field is mandatory and always `1`; its absence in `server.log` after a clean upload means the skill was not invoked or step 2 was split into multiple passes (a regression).
4. **Extract.** `unzip -oq "$storagePath" -d "$dest"`. Non-zero exit → refuse with the underlying `unzip` stderr quoted.
5. **Post-extraction realpath check.** For every path emitted by `find "$dest" -mindepth 1 \( -type f -o -type l \)`:
   - If `-type l`, refuse with `symlink-blocked` (ground-truth guard — pre-scan caught most, this catches the rest).
   - Else compute `realpath --relative-to "$dest" "<entry>"`; if it begins with `../`, refuse with `zip-slip-blocked`.
6. **Emit the done log line** — `[skill:unzip] done attachmentId=<uuid> entries=<n> uncompressed=<bytes>`.
7. **Inventory + propose.** Reply to the operator with: top-level entries (first-level subdirectories and top-level files, up to ~20), total file count, total uncompressed bytes. For each entry class, propose **one** concrete follow-up:
   - `.md`, `.txt` → "ingest into memory via `memory-ingest`?"
   - `.png`, `.jpg`, `.pdf` → "state their location under `extracted/<attachmentId>/` — the operator retrieves them from the Artefacts panel or file share."
   - unknown / binary / source → list only. No proposal.

On any refusal step the operator gets the refusal message verbatim, no re-try, no silent substitution — per the loud-failure protocol in IDENTITY.md.

## Log lines (grep targets)

| When | Line |
|------|------|
| Before extraction | `[skill:unzip] start attachmentId=<uuid> dest=<path> declaredBytes=<n> preflightPasses=1` |
| On success | `[skill:unzip] done attachmentId=<uuid> entries=<n> uncompressed=<bytes>` |
| Oversize refusal | `[skill:unzip] oversize attachmentId=<uuid> uncompressed=<bytes> limit=104857600` |
| Zip-slip refusal | `[skill:unzip] zip-slip-blocked attachmentId=<uuid> entry=<path>` |
| Symlink refusal | `[skill:unzip] symlink-blocked attachmentId=<uuid> entry=<path>` |
| Password / corrupt | `[skill:unzip] unreadable attachmentId=<uuid> reason=<password\|corrupt>` |

One `start` pairs with exactly one of `{done, oversize, zip-slip-blocked, symlink-blocked, unreadable}`. A `start` with no matching terminal line indicates the agent was interrupted mid-extraction — investigate by re-running the flow.

## Out of scope

- `tar`, `tar.gz`, `7z`, `rar` — zip only. The `SUPPORTED_MIME_TYPES` allow-list is the gate.
- Nested-archive recursion — if an extracted file is itself a zip, the operator may manually re-invoke the skill on that file; no auto-recursion.
- Password-protected archives — refused, never prompted.
- Public-chat zip uploads — this skill is admin-only; the public agent never sees attachments of this class.

## See also

- [publish-site](../publish-site/SKILL.md) — when the extracted tree is a static website (HTML + assets), hand off to `publish-site` to move it under `<accountDir>/sites/<slug>/` and emit the canonical path slug.
