# Zip extraction safety reference

This file is the authoritative source for the exact shell commands, attack examples, and refusal messages referenced by [SKILL.md](../SKILL.md). Changes here propagate to every extraction — the skill file inlines the pre-flight bash block; this reference explains *why* each gate is shaped the way it is.

## The single-pass pre-flight

One `unzip -Z "$zip"` call produces the zipinfo column listing. Every attack-class gate reads the same output:

```
Archive:  example.zip
Zip file size: 317 bytes, number of entries: 2
-rw-r--r--  3.0 unx        6 tx stor 26-May-10 21:08 foo.txt
lrwxr-xr-x  3.0 unx       11 bx stor 26-May-10 21:08 link
2 files, 17 bytes uncompressed, 17 bytes compressed:  0.0%
```

Columns: `$1=permissions $2=zip-version $3=os $4=size-bytes $5=text/binary+crypto-flag $6=method $7=date $8=time $9+=name`. Each gate keys off one column; no second `unzip` invocation is needed.

## Attack classes

### 1. Zip slip (path traversal via entry name)

A malicious archive contains an entry whose name starts with `../` (or uses Windows `..\` on some unzip builds). A naive extractor writing relative paths without validation lands the file *outside* the intended destination.

Example listing (`unzip -Z`):
```
?rw-------  2.0 unx        4 b- stor 26-May-11 06:31 ../../escapee.txt
?rw-------  2.0 unx        2 b- stor 26-May-11 06:31 good.txt
```

Pre-scan detection (one awk pass over column 9+):
```awk
NR>2 && $1 ~ /^[-lrwxd?]/ { for (i=9; i<=NF; i++) if ($i ~ /^\.\.\//) { print "blocked: " $i; exit } }
```

Ground-truth backstop: after extraction, for every path under `$dest`, compute `realpath --relative-to "$dest" "<entry>"`. If the relative path begins with `../`, the entry escaped. On macOS InfoZIP 6.00 the extractor silently sanitises `../` prefixes (the file lands inside `$dest` with the prefix stripped), so the realpath check is a no-op in that case — but it still catches the attack on `unzip` builds that don't sanitise. Defense in depth: pre-scan refuses the obvious case, realpath catches the version-specific escape.

Refusal line: `[skill:unzip] zip-slip-blocked attachmentId=<uuid> entry=<path>`.

Refusal message to the operator:

> I rejected this archive because one of its entries tried to write outside the extraction directory (zip-slip attack). Entry: `<path>`. No files were kept.

### 2. Symlink escape

A malicious archive contains a symlink entry — for example, a zero-byte file with permission `lrwxrwxrwx` pointing at `/etc/passwd`. If the extractor materialises it, subsequent operations that follow the symlink read or overwrite the target.

Pre-scan detection (same `unzip -Z` output, column 1):
```awk
NR>2 && $1 ~ /^l/ { print "blocked: " $9; exit }
```
First field beginning with `l` is the POSIX symlink flag in the permission column.

Ground-truth backstop:
```sh
find "$dest" -mindepth 1 -type l
```
Any hit here → refuse even if the pre-scan missed it (defense in depth against `unzip` versions that normalise symlink entries into regular files with `..` payloads, flipping the attack class).

Refusal line: `[skill:unzip] symlink-blocked attachmentId=<uuid> entry=<path>`.

### 3. Decompression bomb

A 42 KB archive expands to 4.5 GB — the classic `42.zip`. Not filesystem-escape but resource exhaustion: fills the account disk, causes OOM on downstream processing.

Pre-scan gate (sum column 4 over the same `unzip -Z` output):
```awk
NR>2 && $1 ~ /^[-lrwxd?]/ { s += $4 } END { if (s > 100*1024*1024) print "oversize: " s }
```
Column 4 is the uncompressed size in bytes per entry. If the running sum exceeds `MAX_ZIP_UNCOMPRESSED_BYTES` (100 MiB, defined in `attachments.ts`), refuse *before* calling `unzip -oq`.

Refusal line: `[skill:unzip] oversize attachmentId=<uuid> uncompressed=<bytes> limit=104857600`.

Note: a hostile archive can lie in its declared sizes. The post-extraction realpath loop runs regardless; if the extraction step itself exhausts disk, `unzip -oq` exits non-zero and the skill refuses with the underlying stderr.

### 4. Password-protected archive

Refused without prompting. The signal is **not** the exit code — `unzip -Z` exits 0 on password-protected archives because the central directory itself is unencrypted; only entry payloads are. The signal is column 5: a leading uppercase `T` (encrypted text) or `B` (encrypted binary). Lowercase `t`/`b` = unencrypted.

Pre-scan detection:
```awk
NR>2 && $5 ~ /^[TB]/ { print "password"; exit }
```

Refusal line: `[skill:unzip] unreadable attachmentId=<uuid> reason=password`.

Refusal message:

> This archive is password-protected. Please extract it locally and upload the individual files you want me to look at.

A genuinely corrupt zip (no central directory) makes `unzip -Z` exit non-zero (typically 9). That is the only exit-code-driven branch: non-zero → `[skill:unzip] unreadable attachmentId=<uuid> reason=corrupt`.

## Canonical commands

| Purpose | Command |
|---------|---------|
| Single-pass pre-flight (listing + permissions + sizes + crypto flag) | `unzip -Z "$zip"` |
| Extract | `unzip -oq "$zip" -d "$dest"` |
| Post-extraction symlink backstop | `find "$dest" -mindepth 1 -type l` |
| Per-entry zip-slip backstop | `realpath --relative-to "$dest" "<entry>"` → must not start with `../` |

All of these are from `unzip` (InfoZIP) + `coreutils` — installed on every Pi image by the installer. No additional dependency.

## Why one pass

Each `Bash` tool_use is one agent turn. `effortToMaxTurns("low") = 5` ([platform/ui/app/lib/claude-agent/budget.ts:175](../../../../ui/app/lib/claude-agent/budget.ts#L175)). The previous flow used three separate `unzip -Z` invocations (entry list, verbose-for-symlink, totals) plus the extraction — four bash calls inside the skill alone, before counting the surrounding specialist dispatch and `plugin-read` calls. At `effort=low` the floor was six turns; one upload exhausted the budget and tripped `error_max_turns`. The fix is structural: every signal the four gates need is already in `unzip -Z`'s column output, so the three pre-flight calls collapse to one.

The observability anchor is `preflightPasses=1` on the `[skill:unzip] start` log line. Absence after a clean upload means either the skill did not activate or the flow regressed back to multiple passes — both regressions are caught by `grep '[skill:unzip] start' server.log | grep -v preflightPasses=1`.

## Why this is a skill, not a tool

Doctrine: the admin agent has `Bash`; the security-critical code path is a sequence of shell commands whose determinism is enforced by the shell primitives themselves, not by LLM reasoning. Wrapping this in an MCP tool would add a translation layer without adding enforcement — a tool wrapper is still LLM-mediated at the decision boundary. The shell script is the deterministic primitive; the skill tells the agent which primitive to invoke, in which order, against which argument.

## Regression suite

[`__tests__/preflight.sh`](../__tests__/preflight.sh) is the executable specification of the four pre-flight gates. Run it after any change to this file or `SKILL.md`. It builds fixtures for each attack class, pipes `unzip -Z` output through the same awk gates inlined in `SKILL.md` step 2, and asserts each gate fires (or stays silent) on the right input. The clean-zip case also traces `unzip` invocations to assert exactly 1 pre-flight + 1 extraction — the structural invariant `preflightPasses=1` is built on.
