@mulmoclaude/core 4.7.0 → 4.9.0

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.
@@ -64,7 +64,7 @@ would run ahead of it and see no `__MC_VIEW`; there is no reason to write one.)
64
64
  window.__MC_VIEW = {
65
65
  slug: "annual-plan", // this collection
66
66
  token: "<scoped capability token>", // Authorization bearer
67
- dataUrl: "http://localhost:3001/api/collections/annual-plan/view-data",
67
+ dataUrl: "http://127.0.0.1:<port>/api/collections/annual-plan/view-data", // absolute, filled in by the host
68
68
  onChange: (cb) => unsubscribe, // live refresh — see "Staying live" below
69
69
  searchQuery: "", // live text in the app's own search box — see "One search box"
70
70
  onSearchQueryChange: (cb) => unsubscribe, // fires when the user types there
@@ -1296,3 +1296,127 @@ Deleting a shared collection is refused outright: the delete can neither archive
1296
1296
  nor remove documents that other members also read, and removing its records
1297
1297
  first does not unlock it. Retiring the whole app is a Firestore project
1298
1298
  administrator's recursive delete, not something this app does.
1299
+
1300
+ ## A messaging bridge's bot does not reply, and nothing errors
1301
+
1302
+ ### Symptoms
1303
+
1304
+ The user talks to the bot on Telegram / Slack / LINE / Discord / …, and nothing
1305
+ comes back. No error in the server log, none from the bridge. Sending again does
1306
+ nothing either.
1307
+
1308
+ ### First, do NOT say "restart the bridge"
1309
+
1310
+ That advice is out of date. Since #3078 the shared client re-reads the token and
1311
+ the port after **every failed connection** and rebuilds its socket when the
1312
+ server has come back as a different generation. A server restart — new token,
1313
+ new port, or both — is handled without touching the bridge.
1314
+
1315
+ ### The bridge tells you where it went — start there
1316
+
1317
+ Every bridge prints its target as it starts — the shared client emits it, so
1318
+ this holds for all of them, not only the ones with a banner:
1319
+
1320
+ ```text
1321
+ Connecting to http://127.0.0.1:3099
1322
+ ```
1323
+
1324
+ It prints again each time the bridge follows the server to a new address, so a
1325
+ bridge that has outlived a restart has several. **Compare the last one** — the
1326
+ earlier lines are where it used to be. Against what the server published:
1327
+
1328
+ ```bash
1329
+ cat "${MULMOCLAUDE_WORKSPACE_PATH:-$HOME/mulmoclaude}/.server-port"
1330
+ ```
1331
+
1332
+ - **They agree** → the address is right; the problem is further in (see below).
1333
+ - **They disagree**, and the bridge says `http://localhost:3001` → that bridge
1334
+ is an npm build from before the port-following fix. Reinstall it
1335
+ (`npm i -g @mulmobridge/<platform>@latest`, or `npx @mulmobridge/<platform>@latest`).
1336
+ Check the banner rather than a version number: the banner is the behaviour,
1337
+ and a version is one more thing to keep current.
1338
+
1339
+ ### `The server has not published a port yet` is waiting, not failing
1340
+
1341
+ ```text
1342
+ The server has not published a port yet — waiting for it rather than guessing.
1343
+ ```
1344
+
1345
+ The server writes `.session-token` before it binds its port, and sandbox setup
1346
+ sits in between — a cold start that builds the Docker image can hold there for
1347
+ minutes. The bridge deliberately refuses to guess `localhost:3001` while holding
1348
+ a freshly minted token, and joins on its own once the port appears. Wait, or
1349
+ check that the server is actually starting.
1350
+
1351
+ ### A bridge that cannot see the workspace needs both variables
1352
+
1353
+ Running on another machine, or in a container without the workspace mounted,
1354
+ means no `.server-port` and no `.session-token`. Such a bridge needs BOTH:
1355
+
1356
+ ```bash
1357
+ MULMOCLAUDE_API_URL=http://127.0.0.1:<port> \
1358
+ MULMOCLAUDE_AUTH_TOKEN=<the same value the server was given> \
1359
+ npx @mulmobridge/<platform>
1360
+ ```
1361
+
1362
+ Two things make this narrower than it looks:
1363
+
1364
+ - **The server binds the IPv4 loopback only** (`app.listen(port, "127.0.0.1")`),
1365
+ so a bridge on another machine cannot reach it by naming the host. It needs a
1366
+ tunnel — an SSH port-forward is the usual one — and then
1367
+ `MULMOCLAUDE_API_URL` points at the LOCAL end of that tunnel, which is why the
1368
+ recipe above still says `127.0.0.1`. Do not "fix" this by making the server
1369
+ listen on `0.0.0.0`: there is no TLS on that port, and the bearer token would
1370
+ cross the network in the clear.
1371
+ - **`MULMOCLAUDE_AUTH_TOKEN` must be set on the SERVER too**, or it regenerates a
1372
+ new token on every start and the pinned one stops matching.
1373
+
1374
+ In a container on the same host, `MULMOCLAUDE_HOST=host.docker.internal` is how
1375
+ the sandbox's own hooks reach the parent server; a bridge there needs the same
1376
+ treatment, and the workspace mounted if you want it to follow the port.
1377
+
1378
+ ### Only then, the platform side
1379
+
1380
+ - The chat ID is not on the bridge's allowlist — the bot answers
1381
+ `"Access denied"` to a stranger, but an allowlist typo simply drops the
1382
+ message.
1383
+ - The platform token (bot token, app token) was revoked or regenerated.
1384
+ - The bridge process is not running at all. Check before assuming anything above.
1385
+
1386
+ ### What to collect if none of it explains the silence
1387
+
1388
+ The bridge's first three lines (they name the transport and the resolved URL),
1389
+ the server's `[server] listening port=…` line, and whether
1390
+ `<workspace>/.server-port` exists. Those three answer "which server, on which
1391
+ port, and did the bridge agree" — which is what every one of these turns out to
1392
+ be.
1393
+
1394
+ ## `renderShapeScript` says Chromium is not installed
1395
+
1396
+ `renderShapeScript` rasterises a ShapeScript model by driving Puppeteer's
1397
+ headless Chromium — the same browser the PDF export uses. Puppeteer downloads
1398
+ it at install time, so this normally just works; a host that set
1399
+ `PUPPETEER_SKIP_DOWNLOAD`, or a sandbox that cannot spawn a browser, has none.
1400
+ The tool then returns, instead of an image path:
1401
+
1402
+ ```
1403
+ renderShapeScript needs Puppeteer's headless Chromium, which this host does
1404
+ not have. Run `npx puppeteer browsers install chrome`, then retry.
1405
+ ```
1406
+
1407
+ What to do:
1408
+
1409
+ 1. Tell the user the one command above.
1410
+ 2. Do NOT retry the call — nothing about the model changed, so the
1411
+ second attempt fails identically.
1412
+ 3. Carry on with `presentShapeScript`, which needs no browser: it
1413
+ validates the geometry headlessly and shows the model to the user
1414
+ in the chat canvas. The only thing you lose is your own ability to
1415
+ LOOK at the render before presenting it, so be more conservative
1416
+ about complex geometry and describe what you built rather than
1417
+ claiming you verified its appearance.
1418
+
1419
+ The same message with `(launch failed: …)` appended means the browser is
1420
+ installed but would not start — usually a sandbox with no permission to
1421
+ spawn it. Report the parenthesised reason to the user rather than the
1422
+ install hint alone.
@@ -67,7 +67,7 @@ See [Wiki](config/helps/wiki.md) for details on how it works.
67
67
  - [Spreadsheet](config/helps/spreadsheet.md) — cell format, formulas, date handling, and format codes for the presentSpreadsheet plugin
68
68
  - [presentHtml](config/helps/presenthtml.md) — self-contained HTML rules and the three-`../` relative-path convention used by the presentHtml plugin to keep generated files portable under `file://`
69
69
  - [Sandbox](config/helps/sandbox.md) — how the Docker sandbox isolates the agent, what it can access, and how to disable it
70
- - [Error recovery](config/helps/error-recovery.md) — the lookup the agent reads on tool failures (gh/git/SSH in the sandbox, Marp PDF, registry import, build/workspace, plugin runtime), plus the four-step triage for when a user reports something broken
70
+ - [Error recovery](config/helps/error-recovery.md) — the lookup the agent reads on tool failures (sandbox gh/git/SSH, Marp PDF, registry import, build/workspace, plugin runtime, a bridge gone quiet), plus the triage for a “broken” report
71
71
  - [Bug-report FAQ](config/helps/bug-report-faq.md) — symptoms that turn out to be configuration or by design (voice input, push, chat titles, journal, connector tools, preset skills, custom views); says where to read the live value, never what it is
72
72
  - [Telegram Bridge](config/helps/telegram.md) — how to talk to MulmoClaude from the Telegram app: creating a bot, starting the bridge, allowlisting chat IDs, commands, and troubleshooting
73
73
  - [Remote host](config/helps/remote-host.md) — drive MulmoClaude from a phone at mulmoserver.web.app: Google sign-in connect, host online vs. offline (queued chats, 7-day expiry), photo attachments, and the security model
@@ -7,7 +7,7 @@ This is useful when you want to reach your MulmoClaude away from your computer
7
7
  ## How It Works
8
8
 
9
9
  - You create a **bot** with Telegram's BotFather; it gives you a token.
10
- - You run a **bridge process** (`yarn telegram`) on the same machine as the MulmoClaude server. The bridge uses your bot token to receive messages from Telegram, forwards them to MulmoClaude over `localhost:3001`, and sends the replies back to the Telegram user.
10
+ - You run a **bridge process** (`yarn telegram`) on the same machine as the MulmoClaude server. The bridge uses your bot token to receive messages from Telegram, forwards them to MulmoClaude over the loopback port the server actually bound, and sends the replies back to the Telegram user. It finds that port itself, and follows it when the server restarts.
11
11
  - A short **allowlist** of Telegram chat IDs controls who can talk to the bot. Everyone else gets `"Access denied"`.
12
12
 
13
13
  Your computer has to be on and connected to the internet for the bot to respond. Close the laptop → the bot goes silent.
@@ -41,7 +41,7 @@ In terminal A, start MulmoClaude:
41
41
  yarn dev
42
42
  ```
43
43
 
44
- Wait until you see `[server] listening port=3001`.
44
+ Wait until you see `[server] listening port=…`. The number is whatever the server bound: it honours `PORT`, and an implicit default that is already busy walks forward. You do not need to note it — the bridge reads it from the workspace.
45
45
 
46
46
  In terminal B, start the bridge. Leave the allowlist **empty on purpose** for the first run — you will need to discover your own chat ID before you can add it.
47
47
 
@@ -56,9 +56,15 @@ Expected output:
56
56
  ```
57
57
  MulmoClaude Telegram bridge
58
58
  Allowlist: (empty — all chats will be denied)
59
+ Connecting to http://127.0.0.1:<port>
59
60
  Connected (<socket id>).
60
61
  ```
61
62
 
63
+ That third line is the bridge telling you which server it found. If it ever says
64
+ `http://localhost:3001` while `.server-port` says something else, that bridge is
65
+ an old build — see "a messaging bridge's bot does not reply" in the error
66
+ recovery help.
67
+
62
68
  ## Step 3 — Find Your Chat ID and Allowlist It
63
69
 
64
70
  1. In Telegram, open your new bot (search the username you picked) and send it any message — `hi` works.
@@ -114,7 +120,7 @@ Any other text is treated as a message to the assistant.
114
120
 
115
121
  ## Troubleshooting
116
122
 
117
- **`Connect error: bearer token rejected`** — MulmoClaude was restarted, so its bearer token changed. Restart `yarn telegram` to pick up the new one. To avoid this, pin `MULMOCLAUDE_AUTH_TOKEN` to the same value on both sides (see `docs/developer.md` §Auth).
123
+ **`Connect error: bearer token rejected`** — MulmoClaude restarted and its bearer token changed. **Do not restart the bridge**: it re-reads the token and the port after a failed connection and reconnects on its own, usually within a second or two. If it is still saying this after that, the server has not finished starting (it writes the token before it binds its port, and a cold start builds the sandbox image in between) — wait for `[server] listening port=…`. Pinning `MULMOCLAUDE_AUTH_TOKEN` on both sides is for a bridge that cannot read the workspace at all, not for this.
118
124
 
119
125
  **`TELEGRAM_ALLOWED_CHAT_IDS: "foo" is not an integer chat id`** — typo in the allowlist. Chat IDs are plain integers only — no spaces, quotes, or `#` prefix. Negative integers (for group chats) are allowed.
120
126
 
@@ -129,7 +135,7 @@ Any other text is treated as a message to the assistant.
129
135
  - The bot token is a password. If it leaks, regenerate it via BotFather's `/revoke`.
130
136
  - The allowlist is the only thing standing between "my friends" and "every Telegram user on Earth". Keep it current — remove chat IDs when you no longer want that person to have access, and restart the bridge.
131
137
  - The bridge logs chat IDs, usernames, and message lengths, but **not** message contents or the bot token. If you need a full audit trail, record it separately.
132
- - The MulmoClaude bearer token never leaves your machine. The bridge only talks to `localhost:3001`; your friends talk to Telegram's servers, which then talk to your bridge.
138
+ - The MulmoClaude bearer token never leaves your machine. The bridge only ever talks to the IPv4 loopback (`127.0.0.1`), whatever port the server bound; your friends talk to Telegram's servers, which then talk to your bridge.
133
139
 
134
140
  ## Full Operator Guide
135
141
 
@@ -30,14 +30,20 @@ function yearMonthUtc(now = /* @__PURE__ */ new Date()) {
30
30
  return `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
31
31
  }
32
32
  /**
33
- * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
34
- * `<epochMs>` suffix keeps freshly-built filenames collision-free without a
35
- * random component. This is what `files.artifacts.write` takes; prefix it with
33
+ * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.
34
+ * This is what `files.artifacts.write` takes; prefix it with
36
35
  * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
36
+ *
37
+ * `<epochMs>` alone separates paths built at different times, but NOT two calls
38
+ * with the same title inside one millisecond — those produce the same name, and
39
+ * the second write silently replaces the first artifact. Pass `suffix` (a short
40
+ * random token, as `shapeArtifactPath` does) wherever concurrent callers can
41
+ * share a title.
37
42
  */
38
43
  function buildArtifactRelPath(params) {
39
- const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true } = params;
40
- const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;
44
+ const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true, suffix } = params;
45
+ const stamp = suffix ? `${now.getTime()}-${suffix}` : `${now.getTime()}`;
46
+ const fileName = `${slugifyArtifact(title, fallback)}-${stamp}${ext}`;
41
47
  return (partitioned ? [
42
48
  dir,
43
49
  yearMonthUtc(now),
@@ -49,13 +55,19 @@ function toWorkspaceArtifactPath(relPath) {
49
55
  return `${ARTIFACTS_ROOT}/${relPath}`;
50
56
  }
51
57
  /**
52
- * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
53
- * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
54
- * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
55
- * — so a workspace-escape judgement can't drift between plugins.
58
+ * True when any `/` or `\`-separated segment of `value` is empty (`//`,
59
+ * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical
60
+ * guard every artifact path check shares — so a workspace-escape judgement
61
+ * can't drift between plugins.
62
+ *
63
+ * Both separators, matching `classifyFilePath`: an artifact path is minted from
64
+ * a slug and so never contains a backslash, while `node:path` on Windows treats
65
+ * one as a separator. Splitting on `/` alone accepted
66
+ * `artifacts/shapes/..\..\secrets.shape` — canonical to this check, traversal
67
+ * to `path.join` (codex on #3056).
56
68
  */
57
69
  function hasUnsafePathSegment(value) {
58
- return value.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
70
+ return value.split(/[/\\]/).some((seg) => seg === "" || seg === "." || seg === "..");
59
71
  }
60
72
  var WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
61
73
  /** True when `value` names a location that does not depend on a base directory.
@@ -1 +1 @@
1
- {"version":3,"file":"paths.cjs","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The\n * `<epochMs>` suffix keeps freshly-built filenames collision-free without a\n * random component. This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true } = params;\n const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),\n * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path\n * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes(\"..\")`\n * — so a workspace-escape judgement can't drift between plugins.\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(\"/\").some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n\n// ── Presentable document paths (presentDocument / presentHtml `path`) ──\n//\n// The two present* tools accept a path to an EXISTING file to display and\n// edit in place. That file is no longer necessarily an artifact the agent\n// wrote: it can be any document in the workspace (MulmoTerminal's workspace\n// IS the git project the user is working in) or, when the host allows it, an\n// absolute path elsewhere on disk.\n//\n// Which of those a value is decides how the host resolves it, so the\n// judgement lives here rather than in each plugin — the plugins may not\n// import one another, and a predicate two of them spell differently is how\n// \"the write site accepts what the refresh site rejects\" bugs start.\n\n/** How a caller-supplied file path must be resolved. `null` = not a usable path. */\nexport type FilePathKind = \"absolute\" | \"relative\";\n\n// `/x`, `C:\\x` / `C:/x`, `\\\\server\\share` (UNC), and the Windows root-relative\n// `\\dir\\x` — which node's `path.resolve` on Windows sends to the drive root, so\n// treating it as relative would mean the classification and the resolution\n// disagreed about where the file is. Windows spellings are recognised on every\n// platform: the value is produced by an LLM or a remote host, not by the local\n// `path` module.\nconst WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\\\/]/;\n\n/** True when `value` names a location that does not depend on a base directory.\n * Exported so a URL builder and a path resolver cannot disagree about which\n * values are rooted. */\nexport function isAbsoluteFilePathValue(value: string): boolean {\n // One leading backslash covers both the UNC `\\\\server\\share` and the Windows\n // root-relative `\\dir\\x`.\n return value.startsWith(\"/\") || value.startsWith(\"\\\\\") || WINDOWS_DRIVE_RE.test(value);\n}\n\n/**\n * Classify a caller-supplied path to a file the host may read and overwrite.\n *\n * Accepts one of `extensions` (compared case-insensitively) and rejects NUL\n * bytes and any `.` / `..` / empty segment — a relative path must be canonical\n * so it can be joined onto a root, and an absolute one must not climb, so\n * neither form can be re-pointed by traversal after the host has vetted it.\n * Returns `\"absolute\"` / `\"relative\"` so the host knows whether to resolve\n * against its workspace root, or `null` when the value is unusable.\n *\n * This is a LEXICAL check only. Existence, file-vs-directory, symlink\n * containment and any host policy about which roots are reachable stay with\n * the host, which is the only layer that can consult the filesystem.\n */\nexport function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null {\n if (!value || value.includes(\"\\0\")) return null;\n const lower = value.toLowerCase();\n if (!extensions.some((ext) => lower.endsWith(ext))) return null;\n const absolute = isAbsoluteFilePathValue(value);\n // Split on both separators: `..` must be refused however the value spells it.\n const segments = value.split(/[/\\\\]/);\n // A leading `/` (or drive / UNC prefix) makes the first segment empty by\n // construction — skip those, then require every remaining segment to be a\n // real name.\n const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;\n if (body.length === 0) return null;\n if (body.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return null;\n return absolute ? \"absolute\" : \"relative\";\n}\n\n/** True when any `/` or `\\`-separated segment starts with a dot. The file\n * servers that hand these pages to a browser refuse dotfile segments (the\n * artifact mounts' `dotfiles: \"deny\"` policy), so a `path` argument bearing\n * one can be accepted by a tool and then never render — the gate and the\n * server have to agree on this, hence one definition. */\nexport function hasDotfileSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((segment) => segment.startsWith(\".\"));\n}\n"],"mappings":";;AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;AAsBA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,SAAS;CAC5E,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,IAAI,QAAQ,IAAI;CAExE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;AAQA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACjF;AAwBA,IAAM,mBAAmB;;;;AAKzB,SAAgB,wBAAwB,OAAwB;CAG9D,OAAO,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,iBAAiB,KAAK,KAAK;AACvF;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAAe,YAAoD;CAClG,IAAI,CAAC,SAAS,MAAM,SAAS,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,WAAW,MAAM,QAAQ,MAAM,SAAS,GAAG,CAAC,GAAG,OAAO;CAC3D,MAAM,WAAW,wBAAwB,KAAK;CAE9C,MAAM,WAAW,MAAM,MAAM,OAAO;CAIpC,MAAM,OAAO,WAAW,SAAS,MAAM,SAAS,WAAW,YAAY,QAAQ,SAAS,CAAC,CAAC,IAAI;CAC9F,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC1F,OAAO,WAAW,aAAa;AACjC;;;;;;AAOA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,QAAQ,WAAW,GAAG,CAAC;AACvE"}
1
+ {"version":3,"file":"paths.cjs","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n /** Extra token appended after the timestamp, for callers that cannot accept a\n * same-millisecond collision (see the note on `buildArtifactRelPath`). */\n suffix?: string;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.\n * This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n *\n * `<epochMs>` alone separates paths built at different times, but NOT two calls\n * with the same title inside one millisecond — those produce the same name, and\n * the second write silently replaces the first artifact. Pass `suffix` (a short\n * random token, as `shapeArtifactPath` does) wherever concurrent callers can\n * share a title.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true, suffix } = params;\n const stamp = suffix ? `${now.getTime()}-${suffix}` : `${now.getTime()}`;\n const fileName = `${slugifyArtifact(title, fallback)}-${stamp}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/` or `\\`-separated segment of `value` is empty (`//`,\n * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical\n * guard every artifact path check shares — so a workspace-escape judgement\n * can't drift between plugins.\n *\n * Both separators, matching `classifyFilePath`: an artifact path is minted from\n * a slug and so never contains a backslash, while `node:path` on Windows treats\n * one as a separator. Splitting on `/` alone accepted\n * `artifacts/shapes/..\\..\\secrets.shape` — canonical to this check, traversal\n * to `path.join` (codex on #3056).\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n\n// ── Presentable document paths (presentDocument / presentHtml `path`) ──\n//\n// The two present* tools accept a path to an EXISTING file to display and\n// edit in place. That file is no longer necessarily an artifact the agent\n// wrote: it can be any document in the workspace (MulmoTerminal's workspace\n// IS the git project the user is working in) or, when the host allows it, an\n// absolute path elsewhere on disk.\n//\n// Which of those a value is decides how the host resolves it, so the\n// judgement lives here rather than in each plugin — the plugins may not\n// import one another, and a predicate two of them spell differently is how\n// \"the write site accepts what the refresh site rejects\" bugs start.\n\n/** How a caller-supplied file path must be resolved. `null` = not a usable path. */\nexport type FilePathKind = \"absolute\" | \"relative\";\n\n// `/x`, `C:\\x` / `C:/x`, `\\\\server\\share` (UNC), and the Windows root-relative\n// `\\dir\\x` — which node's `path.resolve` on Windows sends to the drive root, so\n// treating it as relative would mean the classification and the resolution\n// disagreed about where the file is. Windows spellings are recognised on every\n// platform: the value is produced by an LLM or a remote host, not by the local\n// `path` module.\nconst WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\\\/]/;\n\n/** True when `value` names a location that does not depend on a base directory.\n * Exported so a URL builder and a path resolver cannot disagree about which\n * values are rooted. */\nexport function isAbsoluteFilePathValue(value: string): boolean {\n // One leading backslash covers both the UNC `\\\\server\\share` and the Windows\n // root-relative `\\dir\\x`.\n return value.startsWith(\"/\") || value.startsWith(\"\\\\\") || WINDOWS_DRIVE_RE.test(value);\n}\n\n/**\n * Classify a caller-supplied path to a file the host may read and overwrite.\n *\n * Accepts one of `extensions` (compared case-insensitively) and rejects NUL\n * bytes and any `.` / `..` / empty segment — a relative path must be canonical\n * so it can be joined onto a root, and an absolute one must not climb, so\n * neither form can be re-pointed by traversal after the host has vetted it.\n * Returns `\"absolute\"` / `\"relative\"` so the host knows whether to resolve\n * against its workspace root, or `null` when the value is unusable.\n *\n * This is a LEXICAL check only. Existence, file-vs-directory, symlink\n * containment and any host policy about which roots are reachable stay with\n * the host, which is the only layer that can consult the filesystem.\n */\nexport function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null {\n if (!value || value.includes(\"\\0\")) return null;\n const lower = value.toLowerCase();\n if (!extensions.some((ext) => lower.endsWith(ext))) return null;\n const absolute = isAbsoluteFilePathValue(value);\n // Split on both separators: `..` must be refused however the value spells it.\n const segments = value.split(/[/\\\\]/);\n // A leading `/` (or drive / UNC prefix) makes the first segment empty by\n // construction — skip those, then require every remaining segment to be a\n // real name.\n const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;\n if (body.length === 0) return null;\n if (body.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return null;\n return absolute ? \"absolute\" : \"relative\";\n}\n\n/** True when any `/` or `\\`-separated segment starts with a dot. The file\n * servers that hand these pages to a browser refuse dotfile segments (the\n * artifact mounts' `dotfiles: \"deny\"` policy), so a `path` argument bearing\n * one can be accepted by a tool and then never render — the gate and the\n * server have to agree on this, hence one definition. */\nexport function hasDotfileSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((segment) => segment.startsWith(\".\"));\n}\n"],"mappings":";;AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;;;;;;AA8BA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,MAAM,WAAW;CACpF,MAAM,QAAQ,SAAS,GAAG,IAAI,QAAQ,EAAE,GAAG,WAAW,GAAG,IAAI,QAAQ;CACrE,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,QAAQ;CAEhE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;;;;;;;AAcA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACrF;AAwBA,IAAM,mBAAmB;;;;AAKzB,SAAgB,wBAAwB,OAAwB;CAG9D,OAAO,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,iBAAiB,KAAK,KAAK;AACvF;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAAe,YAAoD;CAClG,IAAI,CAAC,SAAS,MAAM,SAAS,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,WAAW,MAAM,QAAQ,MAAM,SAAS,GAAG,CAAC,GAAG,OAAO;CAC3D,MAAM,WAAW,wBAAwB,KAAK;CAE9C,MAAM,WAAW,MAAM,MAAM,OAAO;CAIpC,MAAM,OAAO,WAAW,SAAS,MAAM,SAAS,WAAW,YAAY,QAAQ,SAAS,CAAC,CAAC,IAAI;CAC9F,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC1F,OAAO,WAAW,aAAa;AACjC;;;;;;AAOA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,QAAQ,WAAW,GAAG,CAAC;AACvE"}
@@ -26,21 +26,35 @@ export interface ArtifactRelPathParams {
26
26
  now?: Date;
27
27
  /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */
28
28
  partitioned?: boolean;
29
+ /** Extra token appended after the timestamp, for callers that cannot accept a
30
+ * same-millisecond collision (see the note on `buildArtifactRelPath`). */
31
+ suffix?: string;
29
32
  }
30
33
  /**
31
- * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
32
- * `<epochMs>` suffix keeps freshly-built filenames collision-free without a
33
- * random component. This is what `files.artifacts.write` takes; prefix it with
34
+ * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.
35
+ * This is what `files.artifacts.write` takes; prefix it with
34
36
  * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
37
+ *
38
+ * `<epochMs>` alone separates paths built at different times, but NOT two calls
39
+ * with the same title inside one millisecond — those produce the same name, and
40
+ * the second write silently replaces the first artifact. Pass `suffix` (a short
41
+ * random token, as `shapeArtifactPath` does) wherever concurrent callers can
42
+ * share a title.
35
43
  */
36
44
  export declare function buildArtifactRelPath(params: ArtifactRelPathParams): string;
37
45
  /** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */
38
46
  export declare function toWorkspaceArtifactPath(relPath: string): string;
39
47
  /**
40
- * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
41
- * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
42
- * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
43
- * — so a workspace-escape judgement can't drift between plugins.
48
+ * True when any `/` or `\`-separated segment of `value` is empty (`//`,
49
+ * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical
50
+ * guard every artifact path check shares — so a workspace-escape judgement
51
+ * can't drift between plugins.
52
+ *
53
+ * Both separators, matching `classifyFilePath`: an artifact path is minted from
54
+ * a slug and so never contains a backslash, while `node:path` on Windows treats
55
+ * one as a separator. Splitting on `/` alone accepted
56
+ * `artifacts/shapes/..\..\secrets.shape` — canonical to this check, traversal
57
+ * to `path.join` (codex on #3056).
44
58
  */
45
59
  export declare function hasUnsafePathSegment(value: string): boolean;
46
60
  /** How a caller-supplied file path must be resolved. `null` = not a usable path. */
@@ -29,14 +29,20 @@ function yearMonthUtc(now = /* @__PURE__ */ new Date()) {
29
29
  return `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
30
30
  }
31
31
  /**
32
- * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The
33
- * `<epochMs>` suffix keeps freshly-built filenames collision-free without a
34
- * random component. This is what `files.artifacts.write` takes; prefix it with
32
+ * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.
33
+ * This is what `files.artifacts.write` takes; prefix it with
35
34
  * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.
35
+ *
36
+ * `<epochMs>` alone separates paths built at different times, but NOT two calls
37
+ * with the same title inside one millisecond — those produce the same name, and
38
+ * the second write silently replaces the first artifact. Pass `suffix` (a short
39
+ * random token, as `shapeArtifactPath` does) wherever concurrent callers can
40
+ * share a title.
36
41
  */
37
42
  function buildArtifactRelPath(params) {
38
- const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true } = params;
39
- const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;
43
+ const { dir, title, ext, fallback, now = /* @__PURE__ */ new Date(), partitioned = true, suffix } = params;
44
+ const stamp = suffix ? `${now.getTime()}-${suffix}` : `${now.getTime()}`;
45
+ const fileName = `${slugifyArtifact(title, fallback)}-${stamp}${ext}`;
40
46
  return (partitioned ? [
41
47
  dir,
42
48
  yearMonthUtc(now),
@@ -48,13 +54,19 @@ function toWorkspaceArtifactPath(relPath) {
48
54
  return `${ARTIFACTS_ROOT}/${relPath}`;
49
55
  }
50
56
  /**
51
- * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),
52
- * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path
53
- * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes("..")`
54
- * — so a workspace-escape judgement can't drift between plugins.
57
+ * True when any `/` or `\`-separated segment of `value` is empty (`//`,
58
+ * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical
59
+ * guard every artifact path check shares — so a workspace-escape judgement
60
+ * can't drift between plugins.
61
+ *
62
+ * Both separators, matching `classifyFilePath`: an artifact path is minted from
63
+ * a slug and so never contains a backslash, while `node:path` on Windows treats
64
+ * one as a separator. Splitting on `/` alone accepted
65
+ * `artifacts/shapes/..\..\secrets.shape` — canonical to this check, traversal
66
+ * to `path.join` (codex on #3056).
55
67
  */
56
68
  function hasUnsafePathSegment(value) {
57
- return value.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
69
+ return value.split(/[/\\]/).some((seg) => seg === "" || seg === "." || seg === "..");
58
70
  }
59
71
  var WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
60
72
  /** True when `value` names a location that does not depend on a base directory.
@@ -1 +1 @@
1
- {"version":3,"file":"paths.js","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs><ext>`. The\n * `<epochMs>` suffix keeps freshly-built filenames collision-free without a\n * random component. This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true } = params;\n const fileName = `${slugifyArtifact(title, fallback)}-${now.getTime()}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/`-segment of `value` is empty (`//`, leading/trailing slash),\n * `.`, or `..`. The lexical traversal / non-canonical guard every artifact path\n * check shares — equivalent to `path.posix.normalize(v) === v && !v.includes(\"..\")`\n * — so a workspace-escape judgement can't drift between plugins.\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(\"/\").some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n\n// ── Presentable document paths (presentDocument / presentHtml `path`) ──\n//\n// The two present* tools accept a path to an EXISTING file to display and\n// edit in place. That file is no longer necessarily an artifact the agent\n// wrote: it can be any document in the workspace (MulmoTerminal's workspace\n// IS the git project the user is working in) or, when the host allows it, an\n// absolute path elsewhere on disk.\n//\n// Which of those a value is decides how the host resolves it, so the\n// judgement lives here rather than in each plugin — the plugins may not\n// import one another, and a predicate two of them spell differently is how\n// \"the write site accepts what the refresh site rejects\" bugs start.\n\n/** How a caller-supplied file path must be resolved. `null` = not a usable path. */\nexport type FilePathKind = \"absolute\" | \"relative\";\n\n// `/x`, `C:\\x` / `C:/x`, `\\\\server\\share` (UNC), and the Windows root-relative\n// `\\dir\\x` — which node's `path.resolve` on Windows sends to the drive root, so\n// treating it as relative would mean the classification and the resolution\n// disagreed about where the file is. Windows spellings are recognised on every\n// platform: the value is produced by an LLM or a remote host, not by the local\n// `path` module.\nconst WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\\\/]/;\n\n/** True when `value` names a location that does not depend on a base directory.\n * Exported so a URL builder and a path resolver cannot disagree about which\n * values are rooted. */\nexport function isAbsoluteFilePathValue(value: string): boolean {\n // One leading backslash covers both the UNC `\\\\server\\share` and the Windows\n // root-relative `\\dir\\x`.\n return value.startsWith(\"/\") || value.startsWith(\"\\\\\") || WINDOWS_DRIVE_RE.test(value);\n}\n\n/**\n * Classify a caller-supplied path to a file the host may read and overwrite.\n *\n * Accepts one of `extensions` (compared case-insensitively) and rejects NUL\n * bytes and any `.` / `..` / empty segment — a relative path must be canonical\n * so it can be joined onto a root, and an absolute one must not climb, so\n * neither form can be re-pointed by traversal after the host has vetted it.\n * Returns `\"absolute\"` / `\"relative\"` so the host knows whether to resolve\n * against its workspace root, or `null` when the value is unusable.\n *\n * This is a LEXICAL check only. Existence, file-vs-directory, symlink\n * containment and any host policy about which roots are reachable stay with\n * the host, which is the only layer that can consult the filesystem.\n */\nexport function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null {\n if (!value || value.includes(\"\\0\")) return null;\n const lower = value.toLowerCase();\n if (!extensions.some((ext) => lower.endsWith(ext))) return null;\n const absolute = isAbsoluteFilePathValue(value);\n // Split on both separators: `..` must be refused however the value spells it.\n const segments = value.split(/[/\\\\]/);\n // A leading `/` (or drive / UNC prefix) makes the first segment empty by\n // construction — skip those, then require every remaining segment to be a\n // real name.\n const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;\n if (body.length === 0) return null;\n if (body.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return null;\n return absolute ? \"absolute\" : \"relative\";\n}\n\n/** True when any `/` or `\\`-separated segment starts with a dot. The file\n * servers that hand these pages to a browser refuse dotfile segments (the\n * artifact mounts' `dotfiles: \"deny\"` policy), so a `path` argument bearing\n * one can be accepted by a tool and then never render — the gate and the\n * server have to agree on this, hence one definition. */\nexport function hasDotfileSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((segment) => segment.startsWith(\".\"));\n}\n"],"mappings":";AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;AAsBA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,SAAS;CAC5E,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,IAAI,QAAQ,IAAI;CAExE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;AAQA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACjF;AAwBA,IAAM,mBAAmB;;;;AAKzB,SAAgB,wBAAwB,OAAwB;CAG9D,OAAO,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,iBAAiB,KAAK,KAAK;AACvF;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAAe,YAAoD;CAClG,IAAI,CAAC,SAAS,MAAM,SAAS,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,WAAW,MAAM,QAAQ,MAAM,SAAS,GAAG,CAAC,GAAG,OAAO;CAC3D,MAAM,WAAW,wBAAwB,KAAK;CAE9C,MAAM,WAAW,MAAM,MAAM,OAAO;CAIpC,MAAM,OAAO,WAAW,SAAS,MAAM,SAAS,WAAW,YAAY,QAAQ,SAAS,CAAC,CAAC,IAAI;CAC9F,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC1F,OAAO,WAAW,aAAa;AACjC;;;;;;AAOA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,QAAQ,WAAW,GAAG,CAAC;AACvE"}
1
+ {"version":3,"file":"paths.js","names":[],"sources":["../../src/artifacts/paths.ts"],"sourcesContent":["// Shared artifact-path builders for the presentation plugins (chart / html /\n// mulmoscript). Browser-safe by design: no node:path / no node:crypto, so it\n// bundles into both the server core and the browser (`./vue`) plugin entries.\n//\n// These are POSIX artifact *wire paths* — stored in JSON and used as the\n// generic `files.artifacts` FileOps keys — so they must ALWAYS join with `/`\n// regardless of host OS. `path.join` would emit `\\` on Windows and corrupt\n// them; `path.posix.join` would drag in node:path and break the browser\n// bundle. So already-sanitised segments are joined with `/` directly (same\n// choice the plugins made before this module existed).\n\nconst MAX_SLUG_LEN = 120;\n\n/** The workspace directory every artifact lives under (`<workspace>/artifacts`). */\nexport const ARTIFACTS_ROOT = \"artifacts\";\n\n/**\n * Lowercase-ASCII slug for a throwaway, timestamped artifact filename. Empty,\n * whitespace-only, and non-ASCII-only titles fall back to `fallback`. Capped at\n * 120 chars so a long LLM title can't blow the filesystem's NAME_MAX.\n *\n * Leading/trailing hyphens are stripped with a linear scan rather than a regex\n * like `/^-+|-+$/` — CodeQL flags the trailing-anchor form as polynomial\n * backtracking on the attacker-influenced (LLM-provided) title. Strip → cap →\n * strip so a cut at the 120-char boundary can't re-expose a trailing hyphen.\n */\nexport function slugifyArtifact(title: string | undefined, fallback: string): string {\n if (!title) return fallback;\n const collapsed = title.toLowerCase().replace(/[^a-z0-9]+/g, \"-\");\n let start = 0;\n let end = collapsed.length;\n while (start < end && collapsed[start] === \"-\") start += 1;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n if (end - start > MAX_SLUG_LEN) end = start + MAX_SLUG_LEN;\n while (end > start && collapsed[end - 1] === \"-\") end -= 1;\n return collapsed.slice(start, end) || fallback;\n}\n\n/** UTC `YYYY/MM` partition (matches the host's #764 artifact sharding). UTC —\n * not local — so a workspace synced across timezones stays in one bucket. */\nexport function yearMonthUtc(now: Date = new Date()): string {\n const year = now.getUTCFullYear();\n const month = String(now.getUTCMonth() + 1).padStart(2, \"0\");\n return `${year}/${month}`;\n}\n\nexport interface ArtifactRelPathParams {\n /** Artifact-kind directory under the artifacts root (e.g. `charts`, `html`, `stories`). */\n dir: string;\n /** Human title the slug is derived from; empty/non-ASCII falls back to `fallback`. */\n title: string | undefined;\n /** File extension INCLUDING the leading dot (e.g. `.html`, `.chart.json`). */\n ext: string;\n /** Slug used when `title` yields nothing (e.g. `chart`, `page`, `story`). */\n fallback: string;\n now?: Date;\n /** Include the `YYYY/MM` partition segment. Default true; stories opt out. */\n partitioned?: boolean;\n /** Extra token appended after the timestamp, for callers that cannot accept a\n * same-millisecond collision (see the note on `buildArtifactRelPath`). */\n suffix?: string;\n}\n\n/**\n * FileOps-relative artifact path: `<dir>[/YYYY/MM]/<slug>-<epochMs>[-<suffix>]<ext>`.\n * This is what `files.artifacts.write` takes; prefix it with\n * `toWorkspaceArtifactPath` for the workspace-relative form shown to the LLM.\n *\n * `<epochMs>` alone separates paths built at different times, but NOT two calls\n * with the same title inside one millisecond — those produce the same name, and\n * the second write silently replaces the first artifact. Pass `suffix` (a short\n * random token, as `shapeArtifactPath` does) wherever concurrent callers can\n * share a title.\n */\nexport function buildArtifactRelPath(params: ArtifactRelPathParams): string {\n const { dir, title, ext, fallback, now = new Date(), partitioned = true, suffix } = params;\n const stamp = suffix ? `${now.getTime()}-${suffix}` : `${now.getTime()}`;\n const fileName = `${slugifyArtifact(title, fallback)}-${stamp}${ext}`;\n const segments = partitioned ? [dir, yearMonthUtc(now), fileName] : [dir, fileName];\n return segments.join(\"/\");\n}\n\n/** Prefix a FileOps-relative artifact path with the workspace `artifacts/` root. */\nexport function toWorkspaceArtifactPath(relPath: string): string {\n return `${ARTIFACTS_ROOT}/${relPath}`;\n}\n\n/**\n * True when any `/` or `\\`-separated segment of `value` is empty (`//`,\n * leading/trailing slash), `.`, or `..`. The lexical traversal / non-canonical\n * guard every artifact path check shares — so a workspace-escape judgement\n * can't drift between plugins.\n *\n * Both separators, matching `classifyFilePath`: an artifact path is minted from\n * a slug and so never contains a backslash, while `node:path` on Windows treats\n * one as a separator. Splitting on `/` alone accepted\n * `artifacts/shapes/..\\..\\secrets.shape` — canonical to this check, traversal\n * to `path.join` (codex on #3056).\n */\nexport function hasUnsafePathSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((seg) => seg === \"\" || seg === \".\" || seg === \"..\");\n}\n\n// ── Presentable document paths (presentDocument / presentHtml `path`) ──\n//\n// The two present* tools accept a path to an EXISTING file to display and\n// edit in place. That file is no longer necessarily an artifact the agent\n// wrote: it can be any document in the workspace (MulmoTerminal's workspace\n// IS the git project the user is working in) or, when the host allows it, an\n// absolute path elsewhere on disk.\n//\n// Which of those a value is decides how the host resolves it, so the\n// judgement lives here rather than in each plugin — the plugins may not\n// import one another, and a predicate two of them spell differently is how\n// \"the write site accepts what the refresh site rejects\" bugs start.\n\n/** How a caller-supplied file path must be resolved. `null` = not a usable path. */\nexport type FilePathKind = \"absolute\" | \"relative\";\n\n// `/x`, `C:\\x` / `C:/x`, `\\\\server\\share` (UNC), and the Windows root-relative\n// `\\dir\\x` — which node's `path.resolve` on Windows sends to the drive root, so\n// treating it as relative would mean the classification and the resolution\n// disagreed about where the file is. Windows spellings are recognised on every\n// platform: the value is produced by an LLM or a remote host, not by the local\n// `path` module.\nconst WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\\\/]/;\n\n/** True when `value` names a location that does not depend on a base directory.\n * Exported so a URL builder and a path resolver cannot disagree about which\n * values are rooted. */\nexport function isAbsoluteFilePathValue(value: string): boolean {\n // One leading backslash covers both the UNC `\\\\server\\share` and the Windows\n // root-relative `\\dir\\x`.\n return value.startsWith(\"/\") || value.startsWith(\"\\\\\") || WINDOWS_DRIVE_RE.test(value);\n}\n\n/**\n * Classify a caller-supplied path to a file the host may read and overwrite.\n *\n * Accepts one of `extensions` (compared case-insensitively) and rejects NUL\n * bytes and any `.` / `..` / empty segment — a relative path must be canonical\n * so it can be joined onto a root, and an absolute one must not climb, so\n * neither form can be re-pointed by traversal after the host has vetted it.\n * Returns `\"absolute\"` / `\"relative\"` so the host knows whether to resolve\n * against its workspace root, or `null` when the value is unusable.\n *\n * This is a LEXICAL check only. Existence, file-vs-directory, symlink\n * containment and any host policy about which roots are reachable stay with\n * the host, which is the only layer that can consult the filesystem.\n */\nexport function classifyFilePath(value: string, extensions: readonly string[]): FilePathKind | null {\n if (!value || value.includes(\"\\0\")) return null;\n const lower = value.toLowerCase();\n if (!extensions.some((ext) => lower.endsWith(ext))) return null;\n const absolute = isAbsoluteFilePathValue(value);\n // Split on both separators: `..` must be refused however the value spells it.\n const segments = value.split(/[/\\\\]/);\n // A leading `/` (or drive / UNC prefix) makes the first segment empty by\n // construction — skip those, then require every remaining segment to be a\n // real name.\n const body = absolute ? segments.slice(segments.findIndex((segment) => segment.length > 0)) : segments;\n if (body.length === 0) return null;\n if (body.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return null;\n return absolute ? \"absolute\" : \"relative\";\n}\n\n/** True when any `/` or `\\`-separated segment starts with a dot. The file\n * servers that hand these pages to a browser refuse dotfile segments (the\n * artifact mounts' `dotfiles: \"deny\"` policy), so a `path` argument bearing\n * one can be accepted by a tool and then never render — the gate and the\n * server have to agree on this, hence one definition. */\nexport function hasDotfileSegment(value: string): boolean {\n return value.split(/[/\\\\]/).some((segment) => segment.startsWith(\".\"));\n}\n"],"mappings":";AAWA,IAAM,eAAe;;AAGrB,IAAa,iBAAiB;;;;;;;;;;;AAY9B,SAAgB,gBAAgB,OAA2B,UAA0B;CACnF,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,YAAY,MAAM,YAAY,CAAC,CAAC,QAAQ,eAAe,GAAG;CAChE,IAAI,QAAQ;CACZ,IAAI,MAAM,UAAU;CACpB,OAAO,QAAQ,OAAO,UAAU,WAAW,KAAK,SAAS;CACzD,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,IAAI,MAAM,QAAQ,cAAc,MAAM,QAAQ;CAC9C,OAAO,MAAM,SAAS,UAAU,MAAM,OAAO,KAAK,OAAO;CACzD,OAAO,UAAU,MAAM,OAAO,GAAG,KAAK;AACxC;;;AAIA,SAAgB,aAAa,sBAAY,IAAI,KAAK,GAAW;CAG3D,OAAO,GAFM,IAAI,eAEP,EAAK,GADD,OAAO,IAAI,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GACtC;AACpB;;;;;;;;;;;;AA8BA,SAAgB,qBAAqB,QAAuC;CAC1E,MAAM,EAAE,KAAK,OAAO,KAAK,UAAU,sBAAM,IAAI,KAAK,GAAG,cAAc,MAAM,WAAW;CACpF,MAAM,QAAQ,SAAS,GAAG,IAAI,QAAQ,EAAE,GAAG,WAAW,GAAG,IAAI,QAAQ;CACrE,MAAM,WAAW,GAAG,gBAAgB,OAAO,QAAQ,EAAE,GAAG,QAAQ;CAEhE,QADiB,cAAc;EAAC;EAAK,aAAa,GAAG;EAAG;CAAQ,IAAI,CAAC,KAAK,QAAQ,EAAA,CAClE,KAAK,GAAG;AAC1B;;AAGA,SAAgB,wBAAwB,SAAyB;CAC/D,OAAO,GAAG,eAAe,GAAG;AAC9B;;;;;;;;;;;;;AAcA,SAAgB,qBAAqB,OAAwB;CAC3D,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,QAAQ,QAAQ,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACrF;AAwBA,IAAM,mBAAmB;;;;AAKzB,SAAgB,wBAAwB,OAAwB;CAG9D,OAAO,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,iBAAiB,KAAK,KAAK;AACvF;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAAe,YAAoD;CAClG,IAAI,CAAC,SAAS,MAAM,SAAS,IAAI,GAAG,OAAO;CAC3C,MAAM,QAAQ,MAAM,YAAY;CAChC,IAAI,CAAC,WAAW,MAAM,QAAQ,MAAM,SAAS,GAAG,CAAC,GAAG,OAAO;CAC3D,MAAM,WAAW,wBAAwB,KAAK;CAE9C,MAAM,WAAW,MAAM,MAAM,OAAO;CAIpC,MAAM,OAAO,WAAW,SAAS,MAAM,SAAS,WAAW,YAAY,QAAQ,SAAS,CAAC,CAAC,IAAI;CAC9F,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,KAAK,MAAM,YAAY,YAAY,MAAM,YAAY,OAAO,YAAY,IAAI,GAAG,OAAO;CAC1F,OAAO,WAAW,aAAa;AACjC;;;;;;AAOA,SAAgB,kBAAkB,OAAwB;CACxD,OAAO,MAAM,MAAM,OAAO,CAAC,CAAC,MAAM,YAAY,QAAQ,WAAW,GAAG,CAAC;AACvE"}
@@ -138,7 +138,7 @@ IconGlyph.props = {
138
138
  };
139
139
  //#endregion
140
140
  //#region ../../node_modules/dompurify/dist/purify.es.mjs
141
- /*! @license DOMPurify 3.4.14 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.14/LICENSE */
141
+ /*! @license DOMPurify 3.4.15 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.15/LICENSE */
142
142
  function _arrayLikeToArray(r, a) {
143
143
  (null == a || a > r.length) && (a = r.length);
144
144
  for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];
@@ -1133,7 +1133,7 @@ var _resolveObjectOption = function _resolveObjectOption(cfg, key, makeFallback)
1133
1133
  function createDOMPurify() {
1134
1134
  let window = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : getGlobal();
1135
1135
  const DOMPurify = (root) => createDOMPurify(root);
1136
- DOMPurify.version = "3.4.14";
1136
+ DOMPurify.version = "3.4.15";
1137
1137
  DOMPurify.removed = [];
1138
1138
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
1139
1139
  DOMPurify.isSupported = false;
@@ -1150,6 +1150,7 @@ function createDOMPurify() {
1150
1150
  const ElementPrototype = Element.prototype;
1151
1151
  const cloneNode = lookupGetter(ElementPrototype, "cloneNode");
1152
1152
  const remove = lookupGetter(ElementPrototype, "remove");
1153
+ const removeAttributeNode = lookupGetter(ElementPrototype, "removeAttributeNode");
1153
1154
  const getNextSibling = lookupGetter(ElementPrototype, "nextSibling");
1154
1155
  const getChildNodes = lookupGetter(ElementPrototype, "childNodes");
1155
1156
  const getParentNode = lookupGetter(ElementPrototype, "parentNode");
@@ -1604,7 +1605,7 @@ function createDOMPurify() {
1604
1605
  */
1605
1606
  const _stripAttributeNode = function _stripAttributeNode(element, attribute, name) {
1606
1607
  try {
1607
- element.removeAttributeNode(attribute);
1608
+ removeAttributeNode(element, attribute);
1608
1609
  } catch (_) {
1609
1610
  try {
1610
1611
  element.removeAttribute(name);
@@ -1677,7 +1678,7 @@ function createDOMPurify() {
1677
1678
  from: element
1678
1679
  });
1679
1680
  try {
1680
- if (attr) element.removeAttributeNode(attr);
1681
+ if (attr) removeAttributeNode(element, attr);
1681
1682
  else element.removeAttribute(name);
1682
1683
  } catch (_) {
1683
1684
  try {
@@ -1923,7 +1924,7 @@ function createDOMPurify() {
1923
1924
  const realTagName = getNodeName ? getNodeName(element) : null;
1924
1925
  if (typeof realTagName !== "string") return false;
1925
1926
  if (transformCaseFunc(realTagName) !== "form") return false;
1926
- return typeof element.nodeName !== "string" || typeof element.textContent !== "string" || typeof element.removeChild !== "function" || element.attributes !== getAttributes(element) || typeof element.removeAttribute !== "function" || typeof element.setAttribute !== "function" || typeof element.namespaceURI !== "string" || typeof element.insertBefore !== "function" || typeof element.hasChildNodes !== "function" || element.nodeType !== getNodeType(element) || element.childNodes !== getChildNodes(element);
1927
+ return typeof element.nodeName !== "string" || typeof element.textContent !== "string" || typeof element.removeChild !== "function" || element.attributes !== getAttributes(element) || typeof element.removeAttribute !== "function" || typeof element.removeAttributeNode !== "function" || typeof element.getAttributeNode !== "function" || typeof element.setAttribute !== "function" || typeof element.namespaceURI !== "string" || typeof element.insertBefore !== "function" || typeof element.hasChildNodes !== "function" || element.nodeType !== getNodeType(element) || element.childNodes !== getChildNodes(element);
1927
1928
  };
1928
1929
  /**
1929
1930
  * Checks whether the given value is a DocumentFragment from any realm.
@@ -2208,24 +2209,38 @@ function createDOMPurify() {
2208
2209
  /**
2209
2210
  * Write a modified attribute value back onto the element. On
2210
2211
  * success, re-probe for clobbering introduced by the new value and
2211
- * remove the element when found; otherwise pop the removal entry
2212
- * recorded by the earlier _removeAttribute (long-standing pairing
2213
- * with the SANITIZE_NAMED_PROPS path - do not "fix" casually). On
2212
+ * remove the element when found; otherwise, when this writeback is the
2213
+ * recreate half of the SANITIZE_NAMED_PROPS remove-and-recreate, pop the
2214
+ * removal entry that path recorded so it does not show as removed. On
2214
2215
  * failure, remove the attribute instead.
2215
2216
  *
2217
+ * Returns true only on a clean write (the value was set and the new value
2218
+ * introduced no clobbering). The caller uses that, together with its own
2219
+ * knowledge of whether this attribute pushed a DOMPurify.removed record, to
2220
+ * decide whether to pop that record. The pop must happen ONLY for the
2221
+ * named-prop remove-and-recreate; popping on any other value change (trim,
2222
+ * template scrubbing, Trusted Types) would consume an unrelated _forceRemove
2223
+ * subtree-cleanup record and let that detached subtree keep a live event
2224
+ * handler through the IN_PLACE neutralization pass (SO-001).
2225
+ *
2216
2226
  * @param currentNode the element carrying the attribute
2217
2227
  * @param name the attribute name as present on the element
2218
2228
  * @param namespaceURI the attribute's namespace, if any
2219
2229
  * @param value the new attribute value
2230
+ * @return true if the value was written without introducing clobbering
2220
2231
  */
2221
2232
  const _setAttributeValue = function _setAttributeValue(currentNode, name, namespaceURI, value) {
2222
2233
  try {
2223
2234
  if (namespaceURI) currentNode.setAttributeNS(namespaceURI, name, value);
2224
2235
  else currentNode.setAttribute(name, value);
2225
- if (_isClobbered(currentNode)) _forceRemove(currentNode);
2226
- else arrayPop(DOMPurify.removed);
2236
+ if (_isClobbered(currentNode)) {
2237
+ _forceRemove(currentNode);
2238
+ return false;
2239
+ }
2240
+ return true;
2227
2241
  } catch (_) {
2228
2242
  _removeAttribute(name, currentNode);
2243
+ return false;
2229
2244
  }
2230
2245
  };
2231
2246
  /**
@@ -2258,6 +2273,7 @@ function createDOMPurify() {
2258
2273
  const lcName = transformCaseFunc(name);
2259
2274
  const initValue = attrValue;
2260
2275
  let value = name === "value" ? initValue : stringTrim(initValue);
2276
+ let recreatedNamedProp = false;
2261
2277
  hookEvent.attrName = lcName;
2262
2278
  hookEvent.attrValue = value;
2263
2279
  hookEvent.keepAttr = true;
@@ -2267,6 +2283,7 @@ function createDOMPurify() {
2267
2283
  if (SANITIZE_NAMED_PROPS && (lcName === "id" || lcName === "name") && stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0) {
2268
2284
  _removeAttribute(name, currentNode, attr);
2269
2285
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
2286
+ recreatedNamedProp = true;
2270
2287
  }
2271
2288
  if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {
2272
2289
  _removeAttribute(name, currentNode, attr);
@@ -2291,7 +2308,9 @@ function createDOMPurify() {
2291
2308
  continue;
2292
2309
  }
2293
2310
  value = _applyTrustedTypesToAttribute(lcTag, lcName, namespaceURI, value);
2294
- if (value !== initValue) _setAttributeValue(currentNode, name, namespaceURI, value);
2311
+ if (value !== initValue) {
2312
+ if (_setAttributeValue(currentNode, name, namespaceURI, value) && recreatedNamedProp) arrayPop(DOMPurify.removed);
2313
+ }
2295
2314
  }
2296
2315
  _executeHooks(hooks.afterSanitizeAttributes, currentNode, null);
2297
2316
  };
@@ -2425,7 +2444,7 @@ function createDOMPurify() {
2425
2444
  if (importedNode.nodeType === NODE_TYPE.element && importedNode.nodeName === "BODY") body = importedNode;
2426
2445
  else if (importedNode.nodeName === "HTML") body = importedNode;
2427
2446
  else body.appendChild(importedNode);
2428
- _sanitizeAttachedShadowRoots(importedNode);
2447
+ _sanitizeAttachedShadowRoots(body);
2429
2448
  } else {
2430
2449
  if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT && dirty.indexOf("<") === -1) return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? _createTrustedHTML(dirty) : dirty;
2431
2450
  body = _initDocument(dirty);