@rubytech/create-realagent 1.0.867 → 1.0.868

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-realagent",
3
- "version": "1.0.867",
3
+ "version": "1.0.868",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -17,18 +17,31 @@ The `[ATTACHMENTS:]` block is contractually present on every turn that originall
17
17
  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.
18
18
 
19
19
  - **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.
20
- - **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.
21
- - **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.
22
- - **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.
23
- - **Password-protected archives are refused with a fixed message.** Never prompt for a key.
20
+ - **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.
21
+ - **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.
22
+ - **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.
23
+ - **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.
24
24
 
25
25
  See [references/safety.md](references/safety.md) for the precise shell commands, attack examples, and refusal templates.
26
26
 
27
27
  ## Flow
28
28
 
29
29
  1. **Resolve paths.** From the `[ATTACHMENTS:]` line read `attachmentId` and `storagePath`. Set `dest="${ACCOUNT_DIR}/extracted/${attachmentId}"`. Create it with `mkdir -p "$dest"`.
30
- 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.
31
- 3. **Emit the start log line** — `[skill:unzip] start attachmentId=<uuid> dest=<path> declaredBytes=<n>` — exactly once, before the extraction command.
30
+ 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:
31
+ ```sh
32
+ ZINFO=$(unzip -Z "$storagePath" 2>&1) || { echo "[skill:unzip] unreadable attachmentId=$attachmentId reason=corrupt"; exit 1; }
33
+ echo "$ZINFO" | awk -v aid="$attachmentId" -v lim=$((100*1024*1024)) '
34
+ NR>2 && $1 ~ /^[-lrwxd?]/ {
35
+ if ($5 ~ /^[TB]/) { print "[skill:unzip] unreadable attachmentId=" aid " reason=password"; bad=1; exit }
36
+ if ($1 ~ /^l/) { print "[skill:unzip] symlink-blocked attachmentId=" aid " entry=" $9; bad=1; exit }
37
+ for (i=9; i<=NF; i++) if ($i ~ /^\.\.\//) { print "[skill:unzip] zip-slip-blocked attachmentId=" aid " entry=" $i; bad=1; exit }
38
+ sum += $4
39
+ }
40
+ END { if (!bad && sum > lim) { print "[skill:unzip] oversize attachmentId=" aid " uncompressed=" sum " limit=" lim; bad=1 }; exit bad }
41
+ ' || exit 1
42
+ DECLARED_BYTES=$(echo "$ZINFO" | awk 'NR>2 && $1 ~ /^[-lrwxd?]/ { s += $4 } END { print s+0 }')
43
+ ```
44
+ 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).
32
45
  4. **Extract.** `unzip -oq "$storagePath" -d "$dest"`. Non-zero exit → refuse with the underlying `unzip` stderr quoted.
33
46
  5. **Post-extraction realpath check.** For every path emitted by `find "$dest" -mindepth 1 \( -type f -o -type l \)`:
34
47
  - If `-type l`, refuse with `symlink-blocked` (ground-truth guard — pre-scan caught most, this catches the rest).
@@ -45,7 +58,7 @@ On any refusal step the operator gets the refusal message verbatim, no re-try, n
45
58
 
46
59
  | When | Line |
47
60
  |------|------|
48
- | Before extraction | `[skill:unzip] start attachmentId=<uuid> dest=<path> declaredBytes=<n>` |
61
+ | Before extraction | `[skill:unzip] start attachmentId=<uuid> dest=<path> declaredBytes=<n> preflightPasses=1` |
49
62
  | On success | `[skill:unzip] done attachmentId=<uuid> entries=<n> uncompressed=<bytes>` |
50
63
  | Oversize refusal | `[skill:unzip] oversize attachmentId=<uuid> uncompressed=<bytes> limit=104857600` |
51
64
  | Zip-slip refusal | `[skill:unzip] zip-slip-blocked attachmentId=<uuid> entry=<path>` |
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bash
2
+ # Regression suite for the single-pass pre-flight prescribed by SKILL.md step 2.
3
+ # Each test builds a fixture zip, runs the exact awk one-liners from SKILL.md
4
+ # against `unzip -Z` output, and asserts the expected gate fires.
5
+ #
6
+ # Run from anywhere: `bash platform/plugins/admin/skills/unzip-attachment/__tests__/preflight.sh`
7
+ # Exits 0 if all assertions pass, non-zero on the first failure.
8
+
9
+ set -uo pipefail
10
+
11
+ WORK=$(mktemp -d)
12
+ trap 'rm -rf "$WORK"' EXIT
13
+
14
+ PASS=0
15
+ FAIL=0
16
+ fail() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
17
+ pass() { echo "PASS: $1"; PASS=$((PASS+1)); }
18
+
19
+ # --- The four awk gates from SKILL.md step 2 -----------------------------------
20
+ # Each takes `unzip -Z $zip` on stdin and exits 0 if the gate FIRES (attack
21
+ # detected), non-zero if the zip is clean for that gate.
22
+
23
+ detect_symlink() { awk 'NR>2 && $1 ~ /^l/ { found=1; print $0 } END { exit !found }'; }
24
+ detect_encrypted() { awk 'NR>2 && $5 ~ /^[TB]/ { found=1; print $0 } END { exit !found }'; }
25
+ detect_slip() { awk 'NR>2 && $1 ~ /^[-lrwxd?]/ { for (i=9; i<=NF; i++) if ($i ~ /^\.\.\//) { found=1; print $i } } END { exit !found }'; }
26
+ sum_uncompressed() { awk 'NR>2 && $1 ~ /^[-lrwxd?]/ { s += $4 } END { print s+0 }'; }
27
+ extract_names() { awk 'NR>2 && $1 ~ /^[-lrwxd?]/ { for (i=9; i<=NF; i++) printf "%s%s", $i, (i<NF?" ":"\n") }'; }
28
+
29
+ LIMIT=$((100 * 1024 * 1024))
30
+
31
+ # --- (a) Password-protected archive refused ----------------------------------
32
+ cd "$WORK"
33
+ echo "secret" > a-secret.txt
34
+ zip -e -P testpass a-pw.zip a-secret.txt >/dev/null 2>&1
35
+ if unzip -Z a-pw.zip 2>/dev/null | detect_encrypted >/dev/null; then
36
+ pass "(a) password — col-5 uppercase first letter detected"
37
+ else
38
+ fail "(a) password — col-5 uppercase first letter NOT detected"
39
+ fi
40
+
41
+ # --- (b) Symlink entry refused -----------------------------------------------
42
+ cd "$WORK"
43
+ ln -s /etc/passwd b-link
44
+ echo "ok" > b-ok.txt
45
+ zip -y b-sym.zip b-link b-ok.txt >/dev/null 2>&1
46
+ if unzip -Z b-sym.zip 2>/dev/null | detect_symlink >/dev/null; then
47
+ pass "(b) symlink — col-1 leading 'l' detected"
48
+ else
49
+ fail "(b) symlink — col-1 leading 'l' NOT detected"
50
+ fi
51
+
52
+ # --- (c) Zip-slip entry refused by pre-scan ---------------------------------
53
+ # The PRE-scan must detect `../`-prefixed entry names in `unzip -Z` column 9+
54
+ # BEFORE extraction runs. macOS unzip silently sanitises these paths during
55
+ # extraction (the file lands inside dest with `../` stripped), so the
56
+ # post-extraction realpath check is a defense-in-depth backstop for unzip
57
+ # versions that don't sanitise — not the primary gate.
58
+ # zip(1) refuses `..` paths, so python forges the central-directory entry.
59
+ cd "$WORK"
60
+ python3 - <<'PY' >/dev/null 2>&1
61
+ import zipfile
62
+ with zipfile.ZipFile("c-slip.zip", "w") as z:
63
+ z.writestr("../../escapee.txt", "evil payload")
64
+ z.writestr("good.txt", "ok")
65
+ PY
66
+ if unzip -Z c-slip.zip 2>/dev/null | detect_slip >/dev/null; then
67
+ pass "(c) zip-slip — pre-scan caught '../' in entry name"
68
+ else
69
+ fail "(c) zip-slip — pre-scan missed '../' in entry name"
70
+ fi
71
+
72
+ # --- (d) Oversize archive refused (declared > 100 MiB) -----------------------
73
+ # Building a 100 MiB+ fixture is expensive on disk and CI. Instead, simulate
74
+ # the awk gate against a synthetic `unzip -Z`-shaped fixture whose column-4
75
+ # sum exceeds the limit. This tests the *gate logic*, not unzip itself.
76
+ cd "$WORK"
77
+ cat > d-zfake.txt <<'FAKE'
78
+ Archive: d-big.zip
79
+ Zip file size: 999 bytes, number of entries: 2
80
+ -rw-r--r-- 3.0 unx 60000000 tx stor 26-May-10 21:08 big1.bin
81
+ -rw-r--r-- 3.0 unx 60000000 tx stor 26-May-10 21:08 big2.bin
82
+ 2 files, 120000000 bytes uncompressed, 999 bytes compressed: 0.0%
83
+ FAKE
84
+ SUM=$(sum_uncompressed < d-zfake.txt)
85
+ if [ "$SUM" -gt "$LIMIT" ]; then
86
+ pass "(d) oversize — sum=$SUM > limit=$LIMIT detected"
87
+ else
88
+ fail "(d) oversize — sum=$SUM did not exceed limit=$LIMIT (gate broken)"
89
+ fi
90
+
91
+ # --- (e) Clean zip = exactly 1 pre-flight + 1 extraction ---------------------
92
+ cd "$WORK"
93
+ mkdir e-stage && cd e-stage
94
+ echo "alpha" > a.txt
95
+ mkdir sub && echo "beta" > sub/b.txt
96
+ zip -r ../e-clean.zip a.txt sub >/dev/null 2>&1
97
+ cd "$WORK"
98
+
99
+ # Wrap unzip in a tracer that increments a counter file per invocation, scoped
100
+ # by the flag combination so we can count pre-flight vs extraction calls.
101
+ TRACE_DIR=$(mktemp -d)
102
+ mkdir -p "$TRACE_DIR/bin"
103
+ cat > "$TRACE_DIR/bin/unzip" <<TRACE
104
+ #!/usr/bin/env bash
105
+ # Classify the call: -Z* = pre-flight (zipinfo mode), -o* = extraction.
106
+ case "\$1" in
107
+ -Z*) echo "Z" >> "$TRACE_DIR/calls" ;;
108
+ -o*) echo "O" >> "$TRACE_DIR/calls" ;;
109
+ -t*) echo "T" >> "$TRACE_DIR/calls" ;;
110
+ *) echo "?" >> "$TRACE_DIR/calls" ;;
111
+ esac
112
+ exec /usr/bin/unzip "\$@"
113
+ TRACE
114
+ chmod +x "$TRACE_DIR/bin/unzip"
115
+ : > "$TRACE_DIR/calls"
116
+
117
+ # Run the prescribed clean-path: 1 `unzip -Z` + 1 `unzip -oq`.
118
+ mkdir e-dest
119
+ PATH="$TRACE_DIR/bin:/usr/bin:/bin" unzip -Z e-clean.zip >/dev/null 2>&1
120
+ PATH="$TRACE_DIR/bin:/usr/bin:/bin" unzip -oq e-clean.zip -d e-dest >/dev/null 2>&1
121
+
122
+ Z_COUNT=$(grep -c '^Z$' "$TRACE_DIR/calls" || true)
123
+ O_COUNT=$(grep -c '^O$' "$TRACE_DIR/calls" || true)
124
+ T_COUNT=$(grep -c '^T$' "$TRACE_DIR/calls" || true)
125
+
126
+ if [ "$Z_COUNT" = "1" ] && [ "$O_COUNT" = "1" ] && [ "$T_COUNT" = "0" ]; then
127
+ pass "(e) clean — exactly 1 pre-flight + 1 extraction (Z=$Z_COUNT O=$O_COUNT T=$T_COUNT)"
128
+ else
129
+ fail "(e) clean — wrong invocation count Z=$Z_COUNT O=$O_COUNT T=$T_COUNT"
130
+ fi
131
+
132
+ # --- (f) Filenames with spaces survive name extraction -----------------------
133
+ cd "$WORK"
134
+ mkdir f-stage && cd f-stage
135
+ echo "hello" > "hello world.txt"
136
+ zip ../f-space.zip "hello world.txt" >/dev/null 2>&1
137
+ cd "$WORK"
138
+ NAMES=$(unzip -Z f-space.zip 2>/dev/null | extract_names)
139
+ if [ "$NAMES" = "hello world.txt" ]; then
140
+ pass "(f) whitespace name — extracted intact: '$NAMES'"
141
+ else
142
+ fail "(f) whitespace name — corrupted: got '$NAMES' want 'hello world.txt'"
143
+ fi
144
+
145
+ # --- Summary -----------------------------------------------------------------
146
+ echo
147
+ echo "RESULT: $PASS passed, $FAIL failed"
148
+ exit "$FAIL"
@@ -1,6 +1,20 @@
1
1
  # Zip extraction safety reference
2
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.
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 the pre-flight bash block; this reference explains *why* each gate is shaped the way it is.
4
+
5
+ ## The single-pass pre-flight
6
+
7
+ One `unzip -Z "$zip"` call produces the zipinfo column listing. Every attack-class gate reads the same output:
8
+
9
+ ```
10
+ Archive: example.zip
11
+ Zip file size: 317 bytes, number of entries: 2
12
+ -rw-r--r-- 3.0 unx 6 tx stor 26-May-10 21:08 foo.txt
13
+ lrwxr-xr-x 3.0 unx 11 bx stor 26-May-10 21:08 link
14
+ 2 files, 17 bytes uncompressed, 17 bytes compressed: 0.0%
15
+ ```
16
+
17
+ 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.
4
18
 
5
19
  ## Attack classes
6
20
 
@@ -8,13 +22,20 @@ This file is the authoritative source for the exact shell commands, attack examp
8
22
 
9
23
  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
24
 
11
- Example listing (`unzip -Z1`):
25
+ Example listing (`unzip -Z`):
12
26
  ```
13
- good/readme.md
14
- ../../etc/cron.d/malicious
27
+ ?rw------- 2.0 unx 4 b- stor 26-May-11 06:31 ../../escapee.txt
28
+ ?rw------- 2.0 unx 2 b- stor 26-May-11 06:31 good.txt
15
29
  ```
16
30
 
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>`.
31
+ Pre-scan detection (one awk pass over column 9+):
32
+ ```awk
33
+ NR>2 && $1 ~ /^[-lrwxd?]/ { for (i=9; i<=NF; i++) if ($i ~ /^\.\.\//) { print "blocked: " $i; exit } }
34
+ ```
35
+
36
+ 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.
37
+
38
+ Refusal line: `[skill:unzip] zip-slip-blocked attachmentId=<uuid> entry=<path>`.
18
39
 
19
40
  Refusal message to the operator:
20
41
 
@@ -24,9 +45,9 @@ Refusal message to the operator:
24
45
 
25
46
  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
47
 
27
- Pre-scan detection:
28
- ```sh
29
- unzip -Z -v "$zip" | awk 'NR>3 && $1 ~ /^l/ { print $NF; found=1 } END { exit !found }'
48
+ Pre-scan detection (same `unzip -Z` output, column 1):
49
+ ```awk
50
+ NR>2 && $1 ~ /^l/ { print "blocked: " $9; exit }
30
51
  ```
31
52
  First field beginning with `l` is the POSIX symlink flag in the permission column.
32
53
 
@@ -42,12 +63,11 @@ Refusal line: `[skill:unzip] symlink-blocked attachmentId=<uuid> entry=<path>`.
42
63
 
43
64
  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
65
 
45
- Pre-scan gate:
46
- ```sh
47
- unzip -Zt "$zip"
48
- # → "N files, X bytes uncompressed, Y bytes compressed: Z%"
66
+ Pre-scan gate (sum column 4 over the same `unzip -Z` output):
67
+ ```awk
68
+ NR>2 && $1 ~ /^[-lrwxd?]/ { s += $4 } END { if (s > 100*1024*1024) print "oversize: " s }
49
69
  ```
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`.
70
+ 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`.
51
71
 
52
72
  Refusal line: `[skill:unzip] oversize attachmentId=<uuid> uncompressed=<bytes> limit=104857600`.
53
73
 
@@ -55,7 +75,12 @@ Note: a hostile archive can lie in its declared sizes. The post-extraction realp
55
75
 
56
76
  ### 4. Password-protected archive
57
77
 
58
- Refused without prompting. `unzip -Z -1` exits non-zero for encrypted entries (or the `-v` listing shows `E` in the attribute column).
78
+ 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.
79
+
80
+ Pre-scan detection:
81
+ ```awk
82
+ NR>2 && $5 ~ /^[TB]/ { print "password"; exit }
83
+ ```
59
84
 
60
85
  Refusal line: `[skill:unzip] unreadable attachmentId=<uuid> reason=password`.
61
86
 
@@ -63,19 +88,29 @@ Refusal message:
63
88
 
64
89
  > This archive is password-protected. Please extract it locally and upload the individual files you want me to look at.
65
90
 
91
+ 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`.
92
+
66
93
  ## Canonical commands
67
94
 
68
95
  | Purpose | Command |
69
96
  |---------|---------|
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"` |
97
+ | Single-pass pre-flight (listing + permissions + sizes + crypto flag) | `unzip -Z "$zip"` |
73
98
  | Extract | `unzip -oq "$zip" -d "$dest"` |
74
99
  | 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 `../` |
100
+ | Per-entry zip-slip backstop | `realpath --relative-to "$dest" "<entry>"` → must not start with `../` |
76
101
 
77
102
  All of these are from `unzip` (InfoZIP) + `coreutils` — installed on every Pi image by the installer. No additional dependency.
78
103
 
104
+ ## Why one pass
105
+
106
+ 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.
107
+
108
+ 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`.
109
+
79
110
  ## Why this is a skill, not a tool
80
111
 
81
112
  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.
113
+
114
+ ## Regression suite
115
+
116
+ [`__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.