@rubytech/create-realagent 1.0.867 → 1.0.869
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-mcp/dist/__tests__/warnings-envelope.test.d.ts +2 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.js +140 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/warnings-envelope.test.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.d.ts +85 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.js +93 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-read.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/index.js +82 -11
- package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-mcp/src/__tests__/warnings-envelope.test.ts +151 -0
- package/payload/platform/lib/graph-mcp/src/cypher-shim-read.ts +141 -0
- package/payload/platform/lib/graph-mcp/src/index.ts +107 -11
- package/payload/platform/plugins/admin/PLUGIN.md +2 -1
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.d.ts +2 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.js +106 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/public-hostname.test.js.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +34 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.d.ts +21 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.js +54 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/public-hostname.js.map +1 -0
- package/payload/platform/plugins/admin/skills/publish-site/SKILL.md +2 -3
- 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/plugins/docs/references/internals.md +2 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
- package/payload/server/chunk-B5VSPQQP.js +11320 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ResolverSession {
|
|
2
|
+
run(cypher: string, params: Record<string, unknown>): Promise<{
|
|
3
|
+
records: Array<{
|
|
4
|
+
get: (k: string) => unknown;
|
|
5
|
+
}>;
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
export interface PublicHostnameHit {
|
|
9
|
+
hostname: string;
|
|
10
|
+
isApex: boolean;
|
|
11
|
+
tunnelId: string;
|
|
12
|
+
}
|
|
13
|
+
export interface PublicHostnameMiss {
|
|
14
|
+
hostname: null;
|
|
15
|
+
isApex: null;
|
|
16
|
+
tunnelId: null;
|
|
17
|
+
reason: "no-hostname" | "no-tunnel";
|
|
18
|
+
}
|
|
19
|
+
export type PublicHostnameResult = PublicHostnameHit | PublicHostnameMiss;
|
|
20
|
+
export declare function resolvePublicHostname(session: ResolverSession, accountId: string): Promise<PublicHostnameResult>;
|
|
21
|
+
//# sourceMappingURL=public-hostname.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-hostname.d.ts","sourceRoot":"","sources":["../../src/lib/public-hostname.ts"],"names":[],"mappings":"AAmBA,MAAM,WAAW,eAAe;IAC9B,GAAG,CACD,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC;QAAE,OAAO,EAAE,KAAK,CAAC;YAAE,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC,CAAC;CACjE;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,IAAI,CAAC;IACf,MAAM,EAAE,IAAI,CAAC;IACb,QAAQ,EAAE,IAAI,CAAC;IACf,MAAM,EAAE,aAAa,GAAG,WAAW,CAAC;CACrC;AAED,MAAM,MAAM,oBAAoB,GAAG,iBAAiB,GAAG,kBAAkB,CAAC;AAsB1E,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,oBAAoB,CAAC,CAkB/B"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Task 970 — public-hostname deterministic resolver.
|
|
2
|
+
//
|
|
3
|
+
// Backs the `mcp__admin__public-hostname` MCP tool. Returns the operator's
|
|
4
|
+
// canonical public hostname in one tool call so the agent following the
|
|
5
|
+
// publish-site skill never has to guess the property name on
|
|
6
|
+
// `:CloudflareHostname` nodes — the failure mode that produced the third
|
|
7
|
+
// `llm-framing-deterministic` recurrence (cf. failure marker
|
|
8
|
+
// `676504f1` at platform/ui/app/lib/claude-agent/spawn-env.ts:334-336).
|
|
9
|
+
//
|
|
10
|
+
// Contract:
|
|
11
|
+
// - On hit: { hostname: string, isApex: boolean, tunnelId: string }
|
|
12
|
+
// - On miss: { hostname: null, isApex: null, tunnelId: null,
|
|
13
|
+
// reason: "no-hostname" | "no-tunnel" }
|
|
14
|
+
//
|
|
15
|
+
// `reason` distinguishes "tunnel is up but no hostname is bound to it yet"
|
|
16
|
+
// (operator must run setup-hostname) from "no tunnel at all" (operator must
|
|
17
|
+
// run setup-tunnel). Both are actionable diagnoses; "no-tunnel" was the
|
|
18
|
+
// underlying state in the recurrence log.
|
|
19
|
+
const HOSTNAME_CYPHER = "MATCH (h:CloudflareHostname {accountId: $accountId}) " +
|
|
20
|
+
"WHERE NOT h:Trashed " +
|
|
21
|
+
"RETURN h.hostnameValue AS hostname, h.isApex AS isApex, h.tunnelId AS tunnelId " +
|
|
22
|
+
"ORDER BY h.isApex DESC, h.updatedAt DESC " +
|
|
23
|
+
"LIMIT 1";
|
|
24
|
+
const TUNNEL_COUNT_CYPHER = "MATCH (t:CloudflareTunnel {accountId: $accountId}) " +
|
|
25
|
+
"WHERE NOT t:Trashed " +
|
|
26
|
+
"RETURN count(t) AS n";
|
|
27
|
+
function toNumber(v) {
|
|
28
|
+
if (typeof v === "number")
|
|
29
|
+
return v;
|
|
30
|
+
if (v && typeof v === "object" && "toNumber" in v && typeof v.toNumber === "function") {
|
|
31
|
+
return v.toNumber();
|
|
32
|
+
}
|
|
33
|
+
return Number(v);
|
|
34
|
+
}
|
|
35
|
+
export async function resolvePublicHostname(session, accountId) {
|
|
36
|
+
const hostnameRes = await session.run(HOSTNAME_CYPHER, { accountId });
|
|
37
|
+
if (hostnameRes.records.length > 0) {
|
|
38
|
+
const r = hostnameRes.records[0];
|
|
39
|
+
return {
|
|
40
|
+
hostname: r.get("hostname"),
|
|
41
|
+
isApex: r.get("isApex"),
|
|
42
|
+
tunnelId: r.get("tunnelId"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const tunnelRes = await session.run(TUNNEL_COUNT_CYPHER, { accountId });
|
|
46
|
+
const n = tunnelRes.records.length > 0 ? toNumber(tunnelRes.records[0].get("n")) : 0;
|
|
47
|
+
return {
|
|
48
|
+
hostname: null,
|
|
49
|
+
isApex: null,
|
|
50
|
+
tunnelId: null,
|
|
51
|
+
reason: n > 0 ? "no-hostname" : "no-tunnel",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=public-hostname.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-hostname.js","sourceRoot":"","sources":["../../src/lib/public-hostname.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,EAAE;AACF,2EAA2E;AAC3E,wEAAwE;AACxE,6DAA6D;AAC7D,yEAAyE;AACzE,6DAA6D;AAC7D,wEAAwE;AACxE,EAAE;AACF,YAAY;AACZ,4EAA4E;AAC5E,oEAAoE;AACpE,4DAA4D;AAC5D,EAAE;AACF,2EAA2E;AAC3E,4EAA4E;AAC5E,wEAAwE;AACxE,0CAA0C;AAwB1C,MAAM,eAAe,GACnB,uDAAuD;IACvD,sBAAsB;IACtB,iFAAiF;IACjF,2CAA2C;IAC3C,SAAS,CAAC;AAEZ,MAAM,mBAAmB,GACvB,qDAAqD;IACrD,sBAAsB;IACtB,sBAAsB,CAAC;AAEzB,SAAS,QAAQ,CAAC,CAAU;IAC1B,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC;IACpC,IAAI,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,UAAU,IAAI,CAAC,IAAI,OAAQ,CAAgC,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;QACtH,OAAQ,CAAgC,CAAC,QAAQ,EAAE,CAAC;IACtD,CAAC;IACD,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC;AACnB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,OAAwB,EACxB,SAAiB;IAEjB,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;IACtE,IAAI,WAAW,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACnC,MAAM,CAAC,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;QACjC,OAAO;YACL,QAAQ,EAAE,CAAC,CAAC,GAAG,CAAC,UAAU,CAAW;YACrC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAY;YAClC,QAAQ,EAAE,CAAC,CAAC,GAAG,CAAC,UAAU,CAAW;SACtC,CAAC;IACJ,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;IACxE,MAAM,CAAC,GAAG,SAAS,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACrF,OAAO;QACL,QAAQ,EAAE,IAAI;QACd,MAAM,EAAE,IAAI;QACZ,QAAQ,EAAE,IAAI;QACd,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,WAAW;KAC5C,CAAC;AACJ,CAAC"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Publish Site
|
|
2
2
|
|
|
3
|
-
Move an already-extracted static-site tree into the per-account static-publish surface (`<accountDir>/sites/<slug>/`) and emit exactly one canonical path slug
|
|
3
|
+
Move an already-extracted static-site tree into the per-account static-publish surface (`<accountDir>/sites/<slug>/`) and emit exactly one canonical path slug. Pair the slug with the deterministic public hostname returned by `mcp__admin__public-hostname` (Task 970) to surface the full URL in a single turn.
|
|
4
4
|
|
|
5
5
|
**Invoked from `specialists:content-producer`** when the brief carries a host-website / publish-site / put-online intent (Task 966). Admin's IDENTITY.md routes those intents to that specialist on turn 1; running this skill inline on the main admin runner exhausts the 10-turn budget on per-turn ToolSearch + plugin-read discovery before publish-site executes.
|
|
6
6
|
|
|
@@ -54,7 +54,7 @@ The operator message for `ambiguous-html` names the candidate files and asks the
|
|
|
54
54
|
4. **Choose the canonical path.** `index.html` present at top level → path is `/sites/<slug>/`. Otherwise the single top-level HTML file → path is `/sites/<slug>/<file>.html`.
|
|
55
55
|
5. **Emit.** One log line:
|
|
56
56
|
`[publish-site] url emitted=<path-slug> kind=<index|file>`
|
|
57
|
-
6. **
|
|
57
|
+
6. **Resolve the public hostname and emit the full URL.** Call `mcp__admin__public-hostname` (Task 970 — single deterministic tool, never raw cypher) to fetch this account's hostname. Concatenate `https://<hostname><path-slug>` and surface that one URL to the operator. If the tool returns `reason: no-tunnel` or `reason: no-hostname`, relay the tool's remediation message verbatim — do not improvise the URL. The route at `/sites/*` (see [server/routes/sites.ts](../../../../ui/server/routes/sites.ts)) handles the trailing-slash redirect on the dir form, so the slug is correct as-emitted whether the operator's HTML uses an `index.html` entry-point or a publisher-named landing file.
|
|
58
58
|
|
|
59
59
|
## Log lines (grep targets)
|
|
60
60
|
|
|
@@ -66,7 +66,6 @@ The operator message for `ambiguous-html` names the candidate files and asks the
|
|
|
66
66
|
|
|
67
67
|
## Out of scope
|
|
68
68
|
|
|
69
|
-
- Discovering or emitting the operator's public hostname. The hostname is whatever their tunnel terminates at — not knowable from inside this skill, and not the platform's concern. The operator pairs the slug with their own host root.
|
|
70
69
|
- Cleanup of pre-existing synthetic files left by earlier sessions (e.g. a stray `index.html` produced by an earlier `cp brochure.html index.html` workaround). Refuse with `destination-occupied` and let the operator clean up explicitly.
|
|
71
70
|
- Extraction. `unzip-attachment` already owns the extract step; the source path is its output.
|
|
72
71
|
- DNS, tunnels, certificates, or any public-host configuration. The route at `/sites/*` is the wire contract; this skill is a placement + URL-shape skill only.
|
|
@@ -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.
|
|
@@ -328,6 +328,8 @@ Each row in the Conversations modal exposes a `View logs` row-action that opens
|
|
|
328
328
|
|
|
329
329
|
**Directory canonicalisation.** A request whose disk target is a directory is `301`'d to the trailing-slash form (query string preserved) before any body is served — RFC 3986 §5.3 base resolution requires the trailing slash so relative refs in the served HTML resolve under the directory, not its parent. After the redirect the route serves `<dir>/index.html` if it exists on disk; otherwise `404`. There is **no** implicit-`index.html` invention for missing paths — the publisher owns canonical URLs. A brochure shipped without `index.html` is reached at `/sites/<slug>/<file>.html`, and the admin skill `publish-site` is the sanctioned surface that moves the extracted tree under `<accountDir>/sites/<slug>/` and emits the canonical path slug. Operator-side: drop a brochure at `<accountDir>/sites/properties/<id>/brochure/output/` and it serves at `<public-host>/sites/properties/<id>/brochure/output/brochure.html` (or `<public-host>/sites/properties/<id>/brochure/output/` if that directory contains an `index.html`). See `.docs/web-chat.md` `/sites/*` route entry for the wire contract and `[sites]` log lines (`serve|redirect-trailing-slash|not-found|path-traversal-rejected|symlink-escape-rejected|no-account`).
|
|
330
330
|
|
|
331
|
+
**Deterministic public-hostname surface.** The `<public-host>` half of the URL the operator pastes is resolved by the `mcp__admin__public-hostname` MCP tool — single call against `:CloudflareHostname.hostnameValue` returning `{hostname, isApex, tunnelId}` on hit or `{hostname:null, reason:"no-tunnel"\|"no-hostname"}` on miss. `publish-site` step 6 calls it after the move and emits the full URL (`https://<hostname><path-slug>`) in the same turn. Agents must never write raw cypher to discover the hostname; the property name (`hostnameValue`, not `hostname`) was the canonical recurrence-class failure mode. The graph-mcp shim additionally runs a sequential envelope-warning probe on every read response — when Neo4j emits `gql_status` codes matching `^0[12]N5\d$` (e.g. `01N52` "property does not exist"), the shim stitches them into a prefix content block on the response so property-name misses surface to the agent inline instead of returning silent `[]`. Probe failure is best-effort: the upstream response forwards unchanged with `[mcp:graph] probe-error`.
|
|
332
|
+
|
|
331
333
|
### Cross-tab session rotation
|
|
332
334
|
|
|
333
335
|
When you click "New conversation" in the chat tab, {{productName}} mints a fresh admin session key on the server and clears the old one. Sibling admin tabs (`/graph`, `/data`) opened in the same browser keep working without re-login: the chat tab broadcasts the new key on a same-origin channel so each sibling tab updates its captured key instantly, and any in-flight admin request that 401s with the rotation-orphan code retries once after re-reading the latest key from per-tab storage. If neither path recovers (browser locked down, second 401 after retry, session expired), the tab shows a single banner — "Your admin session was renewed in another tab. Click to reload." — and one click sends you back through login. No silent 401s; no re-clicking through the same trash icon hoping it sticks. See `.docs/web-chat.md` "Cross-tab rotation contract" for the wire-level `code` taxonomy and observability surfaces.
|
|
@@ -3,7 +3,7 @@ name: content-producer
|
|
|
3
3
|
description: "Visual production and static-site hosting — reads from the populated graph to produce visual artifacts (image generation, PDF rendering, component delivery) and hosts already-prepared static sites by extracting attached archives via unzip-attachment then placing the tree under <accountDir>/sites/<slug>/ via publish-site. Delegate for: generating images, saving rendered pages as PDF, or any 'host this website' / 'publish this site' / 'put this online' intent carrying an HTML+assets archive. **Not** document ingestion — graph ingestion of any kind routes to `specialists:database-operator`. Static-site zips are extracted to disk for publication, never written to the graph."
|
|
4
4
|
summary: "Produces visual output from your graph — generates images, renders pages to PDF, and hosts static websites you upload as a zip. For example, when you need a cover image for a brief, want to save a rendered page as PDF, or upload a brochure zip and ask to put it online."
|
|
5
5
|
model: claude-sonnet-4-6
|
|
6
|
-
tools: Bash, mcp__memory__memory-search, mcp__replicate__image-generate, mcp__plugin_playwright_playwright__browser_navigate, mcp__plugin_playwright_playwright__browser_snapshot, mcp__plugin_playwright_playwright__browser_take_screenshot, mcp__plugin_playwright_playwright__browser_pdf_save, mcp__admin__render-component, mcp__admin__file-attach, mcp__admin__plugin-read
|
|
6
|
+
tools: Bash, mcp__memory__memory-search, mcp__replicate__image-generate, mcp__plugin_playwright_playwright__browser_navigate, mcp__plugin_playwright_playwright__browser_snapshot, mcp__plugin_playwright_playwright__browser_take_screenshot, mcp__plugin_playwright_playwright__browser_pdf_save, mcp__admin__render-component, mcp__admin__file-attach, mcp__admin__plugin-read, mcp__admin__public-hostname
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Content Producer
|