@rubytech/create-realagent 1.0.866 → 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 +1 -1
- package/payload/platform/lib/graph-search/dist/index.d.ts +51 -0
- package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-search/dist/index.js +77 -7
- package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-search/src/__tests__/bm25-strong-bypass-threshold.test.ts +126 -0
- package/payload/platform/lib/graph-search/src/__tests__/vector-threshold.test.ts +170 -0
- package/payload/platform/lib/graph-search/src/index.ts +129 -9
- package/payload/platform/plugins/admin/skills/unzip-attachment/SKILL.md +20 -7
- package/payload/platform/plugins/admin/skills/unzip-attachment/__tests__/preflight.sh +148 -0
- package/payload/platform/plugins/admin/skills/unzip-attachment/references/safety.md +53 -18
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/specialists/agents/database-operator.md +1 -1
- package/payload/server/chunk-DHSBEMWW.js +11319 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{Checkbox-BySsatDO.js → Checkbox-B9hff9s8.js} +1 -1
- package/payload/server/public/assets/{admin-CLp1xGlJ.js → admin-Cpi6L_g7.js} +3 -3
- package/payload/server/public/assets/data-Da6iYRW1.js +1 -0
- package/payload/server/public/assets/graph-BHq-JYwV.js +1 -0
- package/payload/server/public/assets/{useAdminFetch-B3MO55eB.js → graph-labels-ChinGFwI.js} +1 -1
- package/payload/server/public/assets/{jsx-runtime-O5ef8xK8.css → jsx-runtime-CVA1ZrPS.css} +1 -1
- package/payload/server/public/assets/page-DqPf65sS.js +50 -0
- package/payload/server/public/assets/page-OVrxtgOZ.js +1 -0
- package/payload/server/public/assets/{public-DRrf63wm.js → public-CJN5KAiK.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-CR8gcELb.js → useVoiceRecorder-DyVx7e7a.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +80 -11
- package/payload/server/public/assets/data-BuuqlV4L.js +0 -1
- package/payload/server/public/assets/graph-CtVITeok.js +0 -1
- package/payload/server/public/assets/page-Ddc_nKh8.js +0 -1
- package/payload/server/public/assets/page-IQBQoOdT.js +0 -50
- /package/payload/server/public/assets/{jsx-runtime-DnY0498s.js → jsx-runtime-nxP_2eNo.js} +0 -0
|
@@ -92,6 +92,20 @@ export interface ScoredNode {
|
|
|
92
92
|
properties: Record<string, unknown>;
|
|
93
93
|
vectorScore: number;
|
|
94
94
|
bm25Score: number;
|
|
95
|
+
/**
|
|
96
|
+
* Task 967 — true when this node entered the merge map via the BM25 path
|
|
97
|
+
* with a raw Lucene score > 0 (literal-token match against the universal
|
|
98
|
+
* fulltext index), or via the keyword-subscriptions property-lookup path.
|
|
99
|
+
* Set BEFORE bm25 normalisation, so a single-hit set whose normalised
|
|
100
|
+
* value collapses to 0.0 still carries the flag.
|
|
101
|
+
*
|
|
102
|
+
* Read by the vector-threshold filter as the carve-out predicate: rows
|
|
103
|
+
* with weak cosine survive when `bm25Hit` is true (operator's literal
|
|
104
|
+
* tokens override the semantic similarity floor). Without this flag the
|
|
105
|
+
* naive `bm25Score > 0` check would drop every bottom-of-set BM25 row
|
|
106
|
+
* because min-max normalisation pins the lowest score to 0.
|
|
107
|
+
*/
|
|
108
|
+
bm25Hit: boolean;
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
export type SearchMode = "hybrid" | "bm25";
|
|
@@ -124,6 +138,23 @@ export interface HybridParams extends Bm25OnlyParams {
|
|
|
124
138
|
* false so embed failure surfaces loudly.
|
|
125
139
|
*/
|
|
126
140
|
degradeOnEmbedFailure?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Task 967 — minimum raw vector cosine in [0,1] required for a row to
|
|
143
|
+
* pass the merge step. Rows below the threshold are dropped UNLESS they
|
|
144
|
+
* also entered via the BM25 path (`bm25Hit === true`) — that carve-out
|
|
145
|
+
* preserves literal-token matches whose embedding happened to score
|
|
146
|
+
* mediocre. Counts surface in HybridResponse so callers can render an
|
|
147
|
+
* "N suppressed — show all" affordance.
|
|
148
|
+
*
|
|
149
|
+
* Undefined ⇒ no threshold (legacy behaviour; memory MCP keeps this).
|
|
150
|
+
* Zero ⇒ explicit "show all" override (admin route's /data and /graph
|
|
151
|
+
* surfaces use this when the operator clicks "show all").
|
|
152
|
+
*
|
|
153
|
+
* Calibration: see `DEFAULT_VECTOR_THRESHOLD` in
|
|
154
|
+
* `platform/ui/server/routes/admin/graph-search.ts` for the route's
|
|
155
|
+
* default; the lib itself takes no opinion on the value.
|
|
156
|
+
*/
|
|
157
|
+
vectorThreshold?: number;
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
export interface HybridResponse {
|
|
@@ -139,6 +170,26 @@ export interface HybridResponse {
|
|
|
139
170
|
* `expandHops === 0` or no merged results.
|
|
140
171
|
*/
|
|
141
172
|
expandMs: number;
|
|
173
|
+
/**
|
|
174
|
+
* Task 967 — operator-facing threshold accounting.
|
|
175
|
+
*
|
|
176
|
+
* `rawMerged` count of nodes in the merge map BEFORE the threshold
|
|
177
|
+
* filter ran (vector + BM25 + keyword-subscriptions
|
|
178
|
+
* union, deduped by nodeId).
|
|
179
|
+
* `suppressed` count of nodes the threshold filter dropped.
|
|
180
|
+
* Always 0 when `vectorThreshold` is undefined.
|
|
181
|
+
* `bm25Bypass` count of nodes that would have been dropped by the
|
|
182
|
+
* vector-cosine floor BUT survived because `bm25Hit`
|
|
183
|
+
* was true. Subset of the rendered set.
|
|
184
|
+
* `threshold` the actual threshold applied (echoes the param);
|
|
185
|
+
* null when no filter ran. Drives the "show all"
|
|
186
|
+
* affordance: if null, no banner; if non-null and
|
|
187
|
+
* `suppressed > 0`, banner with count.
|
|
188
|
+
*/
|
|
189
|
+
rawMerged: number;
|
|
190
|
+
suppressed: number;
|
|
191
|
+
bm25Bypass: number;
|
|
192
|
+
threshold: number | null;
|
|
142
193
|
}
|
|
143
194
|
|
|
144
195
|
export type EmbedFn = (text: string) => Promise<number[]>;
|
|
@@ -332,7 +383,21 @@ export async function hybrid(
|
|
|
332
383
|
bm25Score: normalised[i] ?? 0,
|
|
333
384
|
related: [],
|
|
334
385
|
}));
|
|
335
|
-
|
|
386
|
+
// Task 967 — threshold has no meaning in the degraded path: no embed
|
|
387
|
+
// happened, so there is no vector cosine to compare against. Surface
|
|
388
|
+
// counts in the response (rawMerged = rendered, suppressed = 0,
|
|
389
|
+
// bm25Bypass = 0, threshold = null) so the caller's render path is
|
|
390
|
+
// uniform across hybrid + degraded modes.
|
|
391
|
+
return {
|
|
392
|
+
mode: "bm25",
|
|
393
|
+
results,
|
|
394
|
+
embedError: msg,
|
|
395
|
+
expandMs: 0,
|
|
396
|
+
rawMerged: results.length,
|
|
397
|
+
suppressed: 0,
|
|
398
|
+
bm25Bypass: 0,
|
|
399
|
+
threshold: null,
|
|
400
|
+
};
|
|
336
401
|
}
|
|
337
402
|
|
|
338
403
|
const labelToIndex = await discoverIndexes(session);
|
|
@@ -350,7 +415,15 @@ export async function hybrid(
|
|
|
350
415
|
.map((l) => labelToIndex.get(l))
|
|
351
416
|
.filter((idx): idx is string => idx !== undefined);
|
|
352
417
|
if (indexesToQuery.length === 0) {
|
|
353
|
-
return {
|
|
418
|
+
return {
|
|
419
|
+
mode: "hybrid",
|
|
420
|
+
results: [],
|
|
421
|
+
expandMs: 0,
|
|
422
|
+
rawMerged: 0,
|
|
423
|
+
suppressed: 0,
|
|
424
|
+
bm25Bypass: 0,
|
|
425
|
+
threshold: params.vectorThreshold ?? null,
|
|
426
|
+
};
|
|
354
427
|
}
|
|
355
428
|
} else {
|
|
356
429
|
indexesToQuery = [...new Set(labelToIndex.values())];
|
|
@@ -402,6 +475,7 @@ export async function hybrid(
|
|
|
402
475
|
properties: plainProperties(node.properties),
|
|
403
476
|
vectorScore: score,
|
|
404
477
|
bm25Score: 0,
|
|
478
|
+
bm25Hit: false,
|
|
405
479
|
});
|
|
406
480
|
}
|
|
407
481
|
}
|
|
@@ -458,6 +532,7 @@ export async function hybrid(
|
|
|
458
532
|
const existing = scoreMap.get(nodeId);
|
|
459
533
|
if (existing) {
|
|
460
534
|
existing.bm25Score = Math.max(existing.bm25Score, 1.0);
|
|
535
|
+
existing.bm25Hit = true;
|
|
461
536
|
} else {
|
|
462
537
|
const node = record.get("node") as { properties: Record<string, unknown> };
|
|
463
538
|
scoreMap.set(nodeId, {
|
|
@@ -466,17 +541,48 @@ export async function hybrid(
|
|
|
466
541
|
properties: plainProperties(node.properties),
|
|
467
542
|
vectorScore: 0,
|
|
468
543
|
bm25Score: 1.0,
|
|
544
|
+
bm25Hit: true,
|
|
469
545
|
});
|
|
470
546
|
}
|
|
471
547
|
}
|
|
472
548
|
}
|
|
473
549
|
|
|
474
|
-
// --- Merge
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
550
|
+
// --- Merge, threshold, rank ---
|
|
551
|
+
//
|
|
552
|
+
// Task 967 — vectorThreshold filters BEFORE sort+slice. Order matters:
|
|
553
|
+
// slicing first would consume the slot budget with rows the threshold
|
|
554
|
+
// would have suppressed, under-filling the rendered set. Sorting before
|
|
555
|
+
// filtering would waste cycles ordering rows we are about to drop. So:
|
|
556
|
+
// filter -> sort -> slice. Counts (rawMerged / suppressed / bm25Bypass)
|
|
557
|
+
// are computed against the pre-filter and post-filter populations so the
|
|
558
|
+
// route can render "N suppressed — show all" without a second query.
|
|
559
|
+
//
|
|
560
|
+
// Carve-out: a row with vector cosine below the threshold survives iff
|
|
561
|
+
// bm25Hit is true. The flag (not bm25Score) is the gate — min-max
|
|
562
|
+
// normalisation pins the lowest BM25 row's score to 0.0, so checking
|
|
563
|
+
// bm25Score > 0 would silently drop a literal-token match that just
|
|
564
|
+
// happens to be the worst-scored BM25 hit in the set.
|
|
565
|
+
const allMerged = [...scoreMap.values()].map((node) => ({
|
|
566
|
+
...node,
|
|
567
|
+
combinedScore: VECTOR_WEIGHT * node.vectorScore + BM25_WEIGHT * node.bm25Score,
|
|
568
|
+
}));
|
|
569
|
+
const rawMerged = allMerged.length;
|
|
570
|
+
const threshold = params.vectorThreshold;
|
|
571
|
+
let kept = allMerged;
|
|
572
|
+
let bm25Bypass = 0;
|
|
573
|
+
if (threshold !== undefined) {
|
|
574
|
+
kept = [];
|
|
575
|
+
for (const node of allMerged) {
|
|
576
|
+
if (node.vectorScore >= threshold) {
|
|
577
|
+
kept.push(node);
|
|
578
|
+
} else if (node.bm25Hit) {
|
|
579
|
+
kept.push(node);
|
|
580
|
+
bm25Bypass++;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const suppressed = rawMerged - kept.length;
|
|
585
|
+
const merged = kept
|
|
480
586
|
.sort((a, b) => b.combinedScore - a.combinedScore)
|
|
481
587
|
.slice(0, limit);
|
|
482
588
|
|
|
@@ -561,7 +667,15 @@ export async function hybrid(
|
|
|
561
667
|
}
|
|
562
668
|
}
|
|
563
669
|
|
|
564
|
-
return {
|
|
670
|
+
return {
|
|
671
|
+
mode: "hybrid",
|
|
672
|
+
results,
|
|
673
|
+
expandMs,
|
|
674
|
+
rawMerged,
|
|
675
|
+
suppressed,
|
|
676
|
+
bm25Bypass,
|
|
677
|
+
threshold: threshold ?? null,
|
|
678
|
+
};
|
|
565
679
|
}
|
|
566
680
|
|
|
567
681
|
function mergeBm25Hit(
|
|
@@ -572,6 +686,11 @@ function mergeBm25Hit(
|
|
|
572
686
|
const existing = map.get(hit.nodeId);
|
|
573
687
|
if (existing) {
|
|
574
688
|
existing.bm25Score = Math.max(existing.bm25Score, normalisedScore);
|
|
689
|
+
// Task 967 — set bm25Hit irrespective of normalised value. The hit
|
|
690
|
+
// came from db.index.fulltext.queryNodes (raw Lucene score > 0 by
|
|
691
|
+
// construction); min-max normalisation pinning the lowest row to
|
|
692
|
+
// 0.0 is a presentation artefact, not a "no match" signal.
|
|
693
|
+
existing.bm25Hit = true;
|
|
575
694
|
} else {
|
|
576
695
|
map.set(hit.nodeId, {
|
|
577
696
|
nodeId: hit.nodeId,
|
|
@@ -579,6 +698,7 @@ function mergeBm25Hit(
|
|
|
579
698
|
properties: hit.properties,
|
|
580
699
|
vectorScore: 0,
|
|
581
700
|
bm25Score: normalisedScore,
|
|
701
|
+
bm25Hit: true,
|
|
582
702
|
});
|
|
583
703
|
}
|
|
584
704
|
}
|
|
@@ -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
|
|
21
|
-
- **No entry may be a symlink.** Checked
|
|
22
|
-
- **
|
|
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. **
|
|
31
|
-
|
|
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
|
|
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 -
|
|
25
|
+
Example listing (`unzip -Z`):
|
|
12
26
|
```
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
29
|
-
|
|
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
|
-
```
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
|
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
|
|
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.
|
|
@@ -166,7 +166,7 @@ When the user asks what you can do, answer from the specialist domains, admin-ow
|
|
|
166
166
|
- Never state a future commitment ("I'll flag", "I'll check", "I'll remind") without immediately creating the mechanism to fulfil it — a scheduled event, a task, or a workflow trigger. A commitment without a backing mechanism is a broken promise.
|
|
167
167
|
- Store everything you learn about the business in the graph — not in files.
|
|
168
168
|
- For document ingestion of any kind — PDFs, text, transcripts, web pages, audio, video, single files, archives — delegate to the `specialists:database-operator` specialist. Include the document path, the document subject (account owner, the business, a third party, etc. — ask if not obvious from context), and the scope (admin/shared/public — ask if not obvious). **Not** `specialists:content-producer`. content-producer produces documents from the populated graph; it does not ingest. The two are opposite movements through the graph and must never be conflated.
|
|
169
|
-
- For
|
|
169
|
+
- For any raw cypher against the memory graph — read or write — delegate to the `specialists:database-operator` specialist. Your read surface is the wrapped tools (`memory-search`, `memory-rank`, `conversation-search`, `profile-read`); your write surface is the schema-aware wrappers (`memory-write`, `memory-update`, `memory-find-candidates`, `memory-delete`). Anything that needs a `MATCH ... RETURN`, a property the wrappers do not expose, or a multi-statement transaction is the operator's surface (e.g. property-name reads against `:CloudflareHostname` / `:Task` / `:Person`, edge-shape audits, dedupe merges, label normalisation, prune passes). Do not perform these inline; admin's `# SCHEMA` block lists labels and edges only — it does not carry the property dictionary, so inline cypher routinely targets non-existent properties and the resulting `01N52` warning is not surfaced. **Not** `specialists:personal-assistant` — PA has no graph surface; misdelegation fails at the tool-gate after wasting a turn.
|
|
170
170
|
- For host-website / publish-site / put-online intents — typically a `.zip` attachment with HTML + assets and a request like "host this website" or "put this online" — delegate immediately to the `specialists:content-producer` specialist on turn 1. Do not `ToolSearch` publish-site, do not `plugin-read` the skill inline, do not pre-scan the zip. The specialist owns the `unzip-attachment` → `publish-site` chain and runs the deterministic Bash flow on its own 10-turn budget; running it inline here exhausts the main-runner budget on per-turn discovery (Task 966 evidence: a 2026-05-10 brochure session hit `error_max_turns total_tool_calls=24` before publish-site executed). **Not** `specialists:database-operator` — static-site zips are extracted to disk for publication, never written to the graph.
|
|
171
171
|
|
|
172
172
|
## Proactive Commitment Detection
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: database-operator
|
|
3
|
-
description: "
|
|
3
|
+
description: "Owner of the memory graph — any raw cypher (read or write) against Neo4j routes here, plus all document and archive ingestion (running the universal `document-ingest` skill for unstructured documents — PDF, text, transcript, web page, audio, video — and per-source archive-import skills — LinkedIn Basic Data Export today; CRM-type seed archives as each plugin ships), plus operator-driven graph hygiene (prune orphans, deduplicate entities, add edges, normalise labels), plus one-off raw reads (property-name lookups, edge-shape audits, multi-statement queries) when admin's wrapped read tools — `memory-search`, `memory-rank`, `conversation-search`, `profile-read` — do not expose the property or relationship being asked about. Delegate when the operator uploads any document, drops an archive directory into chat, asks for any graph operation that is not a routine per-turn wrapped write, or asks a factual question whose answer requires a property or relationship admin's wrapped read tools cannot reach."
|
|
4
4
|
summary: "Ingests every unstructured document and external archive into your graph (LinkedIn today; other CRM sources in future) and handles ad-hoc graph tidy-ups on request. For example, when you upload a CV, a pricing guide, or a contract; when you drop a LinkedIn export folder into chat; or when you ask to prune orphan nodes, merge duplicate people, or add edges between entities."
|
|
5
5
|
model: claude-sonnet-4-6
|
|
6
6
|
tools: Read, Bash, Glob, Grep, mcp__graph__maxy-graph-read_neo4j_cypher, mcp__graph__maxy-graph-write_neo4j_cypher, mcp__graph__maxy-graph-get_neo4j_schema, mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memory-delete, mcp__memory__memory-search, mcp__memory__memory-rank, mcp__memory__memory-reindex, mcp__memory__memory-find-candidates, mcp__memory__memory-ingest, mcp__memory__memory-ingest-extract, mcp__memory__memory-ingest-web, mcp__memory__memory-classify, mcp__memory__memory-archive-write, mcp__memory__graph-prune-denylist-list, mcp__memory__graph-prune-denylist-add, mcp__memory__graph-prune-denylist-remove, mcp__contacts__contact-create, mcp__contacts__contact-update, mcp__contacts__contact-lookup, mcp__contacts__contact-list, mcp__tasks__task-create, mcp__admin__file-attach, mcp__admin__plugin-read
|