@rubytech/create-maxy 1.0.695 → 1.0.696

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.695",
3
+ "version": "1.0.696",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -0,0 +1,58 @@
1
+ # Unzip Attachment
2
+
3
+ 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.
4
+
5
+ ## When to Use
6
+
7
+ 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.
8
+
9
+ 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.
10
+
11
+ ## Invariants
12
+
13
+ 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.
14
+
15
+ - **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.
16
+ - **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 via `unzip -Zt` *before* any write.
17
+ - **No entry may be a symlink.** Checked via `unzip -Z1v` output parsing before extraction; the post-extraction loop also runs `find <dest> -type l` as a ground-truth backstop.
18
+ - **Every extracted file's `realpath --relative-to <dest>` must not start with `../`.** This is the canonical zip-slip guard — it catches malicious entry names like `../../etc/passwd` that some older `unzip` builds silently accept.
19
+ - **Password-protected archives are refused with a fixed message.** Never prompt for a key.
20
+
21
+ See [references/safety.md](references/safety.md) for the precise shell commands, attack examples, and refusal templates.
22
+
23
+ ## Flow
24
+
25
+ 1. **Resolve paths.** From the `[ATTACHMENTS:]` line read `attachmentId` and `storagePath`. Set `dest="${ACCOUNT_DIR}/extracted/${attachmentId}"`. Create it with `mkdir -p "$dest"`.
26
+ 2. **Pre-scan for password + symlink + byte total.** Run `unzip -Z -1 "$storagePath"` for the entry list; if it exits non-zero, the archive is corrupt or password-protected — refuse. Run `unzip -Z -v "$storagePath"` and scan the permission column for a leading `l` (symlink) — refuse on match. Run `unzip -Zt "$storagePath"` to obtain the summary line and parse the total uncompressed bytes; if > `100 * 1024 * 1024`, refuse.
27
+ 3. **Emit the start log line** — `[skill:unzip] start attachmentId=<uuid> dest=<path> declaredBytes=<n>` — exactly once, before the extraction command.
28
+ 4. **Extract.** `unzip -oq "$storagePath" -d "$dest"`. Non-zero exit → refuse with the underlying `unzip` stderr quoted.
29
+ 5. **Post-extraction realpath check.** For every path emitted by `find "$dest" -mindepth 1 \( -type f -o -type l \)`:
30
+ - If `-type l`, refuse with `symlink-blocked` (ground-truth guard — pre-scan caught most, this catches the rest).
31
+ - Else compute `realpath --relative-to "$dest" "<entry>"`; if it begins with `../`, refuse with `zip-slip-blocked`.
32
+ 6. **Emit the done log line** — `[skill:unzip] done attachmentId=<uuid> entries=<n> uncompressed=<bytes>`.
33
+ 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:
34
+ - `.md`, `.txt` → "ingest into memory via `memory-ingest`?"
35
+ - `.png`, `.jpg`, `.pdf` → "re-attach via `file-attach` so the user sees the file?"
36
+ - unknown / binary / source → list only. No proposal.
37
+
38
+ 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.
39
+
40
+ ## Log lines (grep targets)
41
+
42
+ | When | Line |
43
+ |------|------|
44
+ | Before extraction | `[skill:unzip] start attachmentId=<uuid> dest=<path> declaredBytes=<n>` |
45
+ | On success | `[skill:unzip] done attachmentId=<uuid> entries=<n> uncompressed=<bytes>` |
46
+ | Oversize refusal | `[skill:unzip] oversize attachmentId=<uuid> uncompressed=<bytes> limit=104857600` |
47
+ | Zip-slip refusal | `[skill:unzip] zip-slip-blocked attachmentId=<uuid> entry=<path>` |
48
+ | Symlink refusal | `[skill:unzip] symlink-blocked attachmentId=<uuid> entry=<path>` |
49
+ | Password / corrupt | `[skill:unzip] unreadable attachmentId=<uuid> reason=<password\|corrupt>` |
50
+
51
+ 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.
52
+
53
+ ## Out of scope
54
+
55
+ - `tar`, `tar.gz`, `7z`, `rar` — zip only. The `SUPPORTED_MIME_TYPES` allow-list is the gate.
56
+ - 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.
57
+ - Password-protected archives — refused, never prompted.
58
+ - Public-chat zip uploads — this skill is admin-only; the public agent never sees attachments of this class.
@@ -0,0 +1,81 @@
1
+ # Zip extraction safety reference
2
+
3
+ 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 none of this so the reference is the single point of truth.
4
+
5
+ ## Attack classes
6
+
7
+ ### 1. Zip slip (path traversal via entry name)
8
+
9
+ 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.
10
+
11
+ Example listing (`unzip -Z1`):
12
+ ```
13
+ good/readme.md
14
+ ../../etc/cron.d/malicious
15
+ ```
16
+
17
+ Ground-truth guard: after extraction, for every path under `$dest`, compute `realpath --relative-to "$dest" "<entry>"`. If the relative path begins with `../`, the entry escaped. Refusal line: `[skill:unzip] zip-slip-blocked attachmentId=<uuid> entry=<path>`.
18
+
19
+ Refusal message to the operator:
20
+
21
+ > 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.
22
+
23
+ ### 2. Symlink escape
24
+
25
+ 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.
26
+
27
+ Pre-scan detection:
28
+ ```sh
29
+ unzip -Z -v "$zip" | awk 'NR>3 && $1 ~ /^l/ { print $NF; found=1 } END { exit !found }'
30
+ ```
31
+ First field beginning with `l` is the POSIX symlink flag in the permission column.
32
+
33
+ Ground-truth backstop:
34
+ ```sh
35
+ find "$dest" -mindepth 1 -type l
36
+ ```
37
+ 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).
38
+
39
+ Refusal line: `[skill:unzip] symlink-blocked attachmentId=<uuid> entry=<path>`.
40
+
41
+ ### 3. Decompression bomb
42
+
43
+ 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.
44
+
45
+ Pre-scan gate:
46
+ ```sh
47
+ unzip -Zt "$zip"
48
+ # → "N files, X bytes uncompressed, Y bytes compressed: Z%"
49
+ ```
50
+ Parse the `X bytes uncompressed` field. If `X > 100 * 1024 * 1024` (the `MAX_ZIP_UNCOMPRESSED_BYTES` export from `attachments.ts`), refuse *before* calling `unzip -oq`.
51
+
52
+ Refusal line: `[skill:unzip] oversize attachmentId=<uuid> uncompressed=<bytes> limit=104857600`.
53
+
54
+ 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.
55
+
56
+ ### 4. Password-protected archive
57
+
58
+ Refused without prompting. `unzip -Z -1` exits non-zero for encrypted entries (or the `-v` listing shows `E` in the attribute column).
59
+
60
+ Refusal line: `[skill:unzip] unreadable attachmentId=<uuid> reason=password`.
61
+
62
+ Refusal message:
63
+
64
+ > This archive is password-protected. Please extract it locally and upload the individual files you want me to look at.
65
+
66
+ ## Canonical commands
67
+
68
+ | Purpose | Command |
69
+ |---------|---------|
70
+ | Entry list (names only, exits non-zero on password/corrupt) | `unzip -Z -1 "$zip"` |
71
+ | Verbose listing with permission + size columns | `unzip -Z -v "$zip"` |
72
+ | Summary totals parsed for byte-sum gate | `unzip -Zt "$zip"` |
73
+ | Extract | `unzip -oq "$zip" -d "$dest"` |
74
+ | Post-extraction symlink backstop | `find "$dest" -mindepth 1 -type l` |
75
+ | Per-entry zip-slip check | `realpath --relative-to "$dest" "<entry>"` → must not start with `../` |
76
+
77
+ All of these are from `unzip` (InfoZIP) + `coreutils` — installed on every Pi image by the installer. No additional dependency.
78
+
79
+ ## Why this is a skill, not a tool
80
+
81
+ Doctrine (Task 664 precedent): 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 — and per [feedback_deterministic_means_remove_llm.md](../../../../../.claude/projects/-Users-neo-getmaxy/memory/feedback_deterministic_means_remove_llm.md), 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.
@@ -0,0 +1,44 @@
1
+ # Admin Chat Attachments
2
+
3
+ What you can drag-and-drop into the admin chat window, what happens to each file, and the size caps.
4
+
5
+ ## Accepted file types
6
+
7
+ | Type | MIME | Notes |
8
+ |------|------|-------|
9
+ | Images | `image/jpeg`, `image/png`, `image/gif`, `image/webp` | Rendered inline by the agent when relevant. |
10
+ | PDF | `application/pdf` | The agent reads the text; scanned PDFs go via OCR if available. |
11
+ | Plain text, Markdown, CSV, HTML | `text/plain`, `text/markdown`, `text/csv`, `text/html` | Read directly. |
12
+ | Calendar | `text/calendar` | Ingested into the graph if the agent finds a reason to keep it. |
13
+ | Voice note | `audio/*` | Transcribed before the message is routed to the agent. |
14
+ | **Zip archive** | `application/zip`, `application/x-zip-compressed` | Unpacked by the agent after safety checks. See below. |
15
+
16
+ Anything else is refused at upload time with a message naming the type.
17
+
18
+ ## Size caps
19
+
20
+ - **Per file:** 20 MB. Enforced at the upload endpoint — files over this limit never reach disk.
21
+ - **Per message:** up to 5 files.
22
+ - **Uncompressed contents of a single zip:** 100 MB. A zip whose declared uncompressed total is over this limit is refused before any byte is extracted (decompression-bomb guard).
23
+
24
+ ## What happens with a zip archive
25
+
26
+ When you drop a `.zip` into chat, the agent:
27
+
28
+ 1. **Checks the archive is safe.** It refuses archives that try to write outside their own extraction folder, contain symlinks, are password-protected, or declare more than 100 MB of uncompressed content. You'll see the exact reason in chat if any check fails.
29
+ 2. **Extracts it to a fresh folder.** Contents land under `{your-account-dir}/extracted/{id}/` — one folder per archive, never mixed.
30
+ 3. **Lists what's in it.** The agent tells you the top-level entries, the total file count, and the uncompressed size.
31
+ 4. **Asks before doing anything else.** For each class of file (text/markdown, images, PDFs, other), it proposes one next step — for example "ingest these notes into memory" or "re-attach the images back to chat so you can see them" — and waits for you to say yes.
32
+
33
+ Nothing is ingested, sent, or acted on automatically. The extraction is local and visible; you decide what happens next.
34
+
35
+ ## What is **not** supported
36
+
37
+ - `tar`, `tar.gz`, `7z`, `rar` — zip only. If you have one of these, unzip/convert locally and upload the zip (or the extracted files directly).
38
+ - Nested archives — a zip-inside-a-zip is extracted one level; you can ask the agent to unpack the inner one afterwards.
39
+ - Password-protected zips — the agent will tell you to unlock locally and re-upload.
40
+ - Uploads larger than 20 MB — split the archive, or upload the individual files.
41
+
42
+ ## Where the files live
43
+
44
+ Uploads go to `{install-dir}/data/uploads/{account-id}/{file-id}/` — outside the platform wipe zone, so they survive re-installs. Extracted zip contents go to `{account-dir}/extracted/{file-id}/`. Both are local to your device.