dsh-mindmap 0.6.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -25,6 +25,12 @@ All notable changes to this project are documented here. Release-specific notes
25
25
 
26
26
  - The panel now reliably auto-opens when the AI completes `mindmap_open` / `mindmap_create`. A structural fingerprint of the session nodes (`nodesFingerprint`) feeds a second `useSession` selector; its value comparison bypasses the reference-equality short-circuit that starved the auto-open effect whenever the host store mutated the nodes array in place.
27
27
  - The "AI 正在打开脑图…" loading state is no longer a dead end. Snapshot documents whose path differs from the tree-click key only by letter case (macOS case-insensitive filesystem) now merge automatically; errored mindmap tool results (`isError` or `ok !== true`) surface as an inline error; a ~30s watchdog switches to a timeout state. Both failure states offer a one-click retry that re-sends the open request.
28
+ - A code-review sweep (`_issues/002`) landed 11 fixes, each with a failing regression test first:
29
+ - **Markdown parsing**: an unclosed `---` frontmatter fence at the top no longer swallows the whole document into an empty tree; the root-title echo merge (`firstTopH1Seen`) is set only by a top-level H1 so an earlier H2 or a heading inside a quote block no longer suppresses it; GFM table column count is pinned to the **separator row** (short rows padded, extra cells dropped) instead of the header; fenced code blocks close only on a matching fence type (`~~~` is no longer ended by a ``` line) and `parseTableRow` honors an escaped `\|` preceded by an escaped backslash (`\\|`).
30
+ - **Document snapshots**: `mergeDocuments` only deletes a dropped local entry when no live snapshot owns the same path, so a recreated file that a snapshot also references keeps its panel openable.
31
+ - **Link rendering**: the bare-URL pattern no longer swallows CJK punctuation/full-width characters (`https://a.com,然后…` stops at the comma); `[text](url)` and bare links render as plain text unless the scheme is http/https/mailto (blocking `javascript:`/`data:`), and `openLink` calls `preventDefault` only when `window.open` actually succeeds, so host-blocked popups fall back to default navigation instead of a dead click.
32
+ - **Host safety & approval**: the tree route and all mindmap path resolutions now reject symlink escapes out of the working directory via a `realpath` walk-up (`resolvesInsideBase`); `requireApproval` gates `mindmap_create` as well as `mindmap_update`; and `listDirectoryLevel` stops iterating once it hits the entry cap (perf) while still flagging truncation.
33
+ - **PNG export**: wide tables measure their box height using the same clamped per-column width the renderer uses, so wrapped cell text no longer overflows the measured box bottom.
28
34
 
29
35
  ## [0.4.1] - 2026-08-24
30
36
 
package/README.md CHANGED
@@ -23,9 +23,9 @@ A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap.
23
23
  - **Single-mindmap mode** — two tabs only: 目录 (tree) and 脑图 (the current mindmap); opening another `.md` replaces the previous one.
24
24
  - **"What you see is what the AI edits"** — when the visible mindmap differs from the AI's working document, the panel automatically asks the AI to open it, keeping the chat focus in sync.
25
25
  - **MarkGrove-style mapping** — heading hierarchy, nested lists (empty items become placeholder nodes), code blocks as leaf nodes, paragraphs as node notes, stable structural IDs, and orthogonal connector lines between nodes.
26
- - **Centered canvas with zoom** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas.
26
+ - **Centered canvas with zoom and pan** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas. The canvas also pans by drag: the **middle button** anywhere (even over a node), the **left button on blank canvas** (the Mac trackpad「click and drag」path), or **Space + left button** when the drag must start on a card. Content follows the pointer 1:1, blank space shows a grab hand, and a 4px threshold separates drag from click — so clicking blank space still clears the selection and clicking a node still focuses it, while a real drag never wipes the selection ring.
27
27
  - **PNG export** — one click on 导出图片 exports the current mindmap.
28
- - **Safety** — `mindmap_update` is approval-free by default (files are git-managed) with a `requireApproval` switch as an escape hatch; the client has **no write path** to the filesystem — every edit goes through the AI tools.
28
+ - **Safety** — `mindmap_create` / `mindmap_update` are approval-free by default (files are git-managed) with a `requireApproval` switch as an escape hatch (when on, both write tools ask); the client has **no write path** to the filesystem — every edit goes through the AI tools.
29
29
 
30
30
  ## Requirements
31
31
 
package/README.zh-CN.md CHANGED
@@ -23,9 +23,9 @@
23
23
  - **单脑图模式**——面板只有「目录」和「脑图」两个 tab,打开新脑图替换旧的那颗。
24
24
  - **「所见即所编」焦点同步**——可见脑图与 AI 工作文档不一致时,面板自动让 AI 打开它,聊天焦点始终跟随你的眼睛。
25
25
  - **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落挂节点说明、稳定结构 ID、节点间直角折线。
26
- - **画布居中与缩放**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。
26
+ - **画布居中、缩放与平移**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。画布还能拖着走:**中键**随处拖(含节点上)、**空白处左键拖**(Mac 触控板「点按并拖移」)、或 **Space + 左键拖**(必须从节点上起手时);内容跟手 1:1,空白处呈抓手光标,4px 阈值把拖与点分开——点空白仍取消选中、点节点仍聚焦,真拖不会误清选中圈。
27
27
  - **PNG 导出**——面板右上角「导出图片」一键导出当前脑图。
28
- - **安全**——`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药;客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
28
+ - **安全**——`mindmap_create`/`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药(开启后两个写工具都走审批);客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
29
29
 
30
30
  ## 环境要求
31
31
 
package/client.js CHANGED
@@ -287,11 +287,12 @@ window.__ModuleLoader__.load({
287
287
  }
288
288
 
289
289
  /** 表格行 → 单元格数组(去首尾空段,保留中间空单元格)。
290
- * 支持 GFM 转义:`\|` 是字面竖线(占位符避位,剥标记后还原),不切单元格。 */
290
+ * 支持 GFM 转义:`\|` 是字面竖线(占位符避位,剥标记后还原),不切单元格;
291
+ * `\\|` 则是已转义的反斜杠 + 真分隔符,照常切开。 */
291
292
  function parseTableRow(line) {
292
293
  return String(line ?? "").trim()
293
294
  .replace(/^\|/, "").replace(/\|$/, "")
294
- .replace(/\\\|/g, "\u0000")
295
+ .replace(/(?<!\\)((?:\\\\)*)\\\|/g, "$1\u0000")
295
296
  .split("|")
296
297
  .map((c) => c.replace(/\u0000/g, "|").trim());
297
298
  }
@@ -333,13 +334,16 @@ window.__ModuleLoader__.load({
333
334
  data: {},
334
335
  };
335
336
  // 根标题回声标记:记录文档常以文件名作首行 H1(如 "# 002-spike结论.md"),
336
- // 而根节点标题就是文件名——首个 H1 与根标题一致(或仅多 .md 后缀)时
337
- // 并入根节点,避免标题显示两次。仅文档顶层参与回声(引用内不算)。
338
- let firstHeadingSeen = false;
337
+ // 而根节点标题就是文件名——首个顶层 H1 与根标题一致(或仅多 .md 后缀)
338
+ // 时并入根节点,避免标题显示两次。只有顶层 H1 参与回声:H2 等低级标题
339
+ // 不消耗名额,引用块递归(echoRoot=false)也不触碰本标记。
340
+ let firstTopH1Seen = false;
339
341
  const lines = String(markdown ?? "").split(/\r?\n/);
340
342
 
341
343
  let start = 0;
342
- // 跳过 YAML frontmatter(--- ... ---)
344
+ // 跳过 YAML frontmatter(--- ... ---):找不到闭合行说明不是
345
+ // frontmatter(如以水平线开头的合法文档),回退普通解析,
346
+ // 否则整篇会被吞成空树。
343
347
  if (lines.length > 0 && /^\s*---\s*$/.test(lines[0])) {
344
348
  for (start = 1; start < lines.length; start++) {
345
349
  if (/^\s*---\s*$/.test(lines[start])) {
@@ -347,6 +351,7 @@ window.__ModuleLoader__.load({
347
351
  break;
348
352
  }
349
353
  }
354
+ if (start >= lines.length) start = 0;
350
355
  }
351
356
 
352
357
  /**
@@ -393,12 +398,16 @@ window.__ModuleLoader__.load({
393
398
 
394
399
  // 围栏代码块:整块成为一个叶节点,标题 = [语言] 首行摘要(盒内紧凑,
395
400
  // 悬停浮层看全文——003 §5.3;data.code 全量保存)。
396
- if (/^\s*(```|~~~)/.test(line)) {
401
+ // GFM:闭合围栏须同字符且不少于开启长度——``` 块里的 ~~~、
402
+ // ```` 块里的 ``` 都是代码内容,不是围栏。
403
+ const fence = /^\s*(`{3,}|~{3,})/.exec(line);
404
+ if (fence) {
397
405
  flushParagraph();
398
406
  listStack = [];
399
- const lang = line.trim().slice(3).trim();
407
+ const lang = line.trim().slice(fence[1].length).trim();
408
+ const fenceClose = new RegExp(`^\\s*${fence[1][0]}{${fence[1].length},}\\s*$`);
400
409
  const buf = [];
401
- for (i += 1; i < lineList.length && !/^\s*(```|~~~)/.test(lineList[i]); i++) buf.push(lineList[i]);
410
+ for (i += 1; i < lineList.length && !fenceClose.test(lineList[i]); i++) buf.push(lineList[i]);
402
411
  const code = buf.join("\n");
403
412
  const firstLine = (code.split("\n")[0] || "").trim();
404
413
  const summary = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
@@ -419,12 +428,15 @@ window.__ModuleLoader__.load({
419
428
  listStack = [];
420
429
  const level = heading[1].length;
421
430
  const text = heading[2].trim() || "(无标题)";
422
- // 首个 H1 与根标题一致(或仅多 .md 后缀)→ 并入根节点,不另建节点。
423
- if (echoRoot && !firstHeadingSeen && level === 1 && (text === root.topic || text === `${root.topic}.md`)) {
424
- firstHeadingSeen = true;
425
- continue;
431
+ // 首个顶层 H1 与根标题一致(或仅多 .md 后缀)→ 并入根节点,不另建节点。
432
+ // 名额只属于顶层 H1:低级标题先行、引用块内出现标题都不影响回声。
433
+ if (echoRoot && level === 1) {
434
+ if (!firstTopH1Seen && (text === root.topic || text === `${root.topic}.md`)) {
435
+ firstTopH1Seen = true;
436
+ continue;
437
+ }
438
+ firstTopH1Seen = true;
426
439
  }
427
- firstHeadingSeen = true;
428
440
  while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) headingStack.pop();
429
441
  const basePath = parentRec() ? parentRec().path : "";
430
442
  const node = {
@@ -475,19 +487,20 @@ window.__ModuleLoader__.load({
475
487
  flushParagraph();
476
488
  listStack = [];
477
489
  i = j - 1;
478
- const header = parseTableRow(rows[0]);
479
- // GFM 对齐契约:列数钉死在分隔行(=表头)。少列补空、
480
- // 多列截断——未转义竖线切碎的行顶多内容错位,网格永不参差。
490
+ // GFM 对齐契约:列数钉死在分隔行。表头与数据行同等待遇:
491
+ // 少列补空、多列截断——未转义竖线切碎的行顶多内容错位,网格永不参差。
492
+ const cols = parseTableRow(rows[1]).length;
481
493
  const toCols = (cells) => {
482
- if (cells.length >= header.length) return cells.slice(0, header.length);
483
- return cells.concat(new Array(header.length - cells.length).fill(""));
494
+ if (cells.length >= cols) return cells.slice(0, cols);
495
+ return cells.concat(new Array(cols - cells.length).fill(""));
484
496
  };
497
+ const header = toCols(parseTableRow(rows[0]));
485
498
  const body = rows.slice(2).map((row) => toCols(parseTableRow(row)));
486
499
  const tableRows = [header].concat(body);
487
500
  appendNode({
488
501
  id: idOf("table", tableRows.map((r) => r.join("\u0001")).join("\u0002"), parentPathOf()),
489
502
  kind: "table",
490
- topic: `${tableRows.length}×${header.length} 表格`,
503
+ topic: `${tableRows.length}×${cols} 表格`,
491
504
  children: [],
492
505
  data: { rows: tableRows },
493
506
  });
@@ -807,7 +820,9 @@ window.__ModuleLoader__.load({
807
820
  const lower = p.toLowerCase();
808
821
  if (snapPaths.some((sp) => sp.toLowerCase() === lower)) dropped.add(p);
809
822
  }
810
- for (const p of dropped) delete byPath[p];
823
+ // 只删本地条目;若该路径同时是存活快照文档
824
+ // (改名后又重建),快照保留,面板照常打开。
825
+ for (const p of dropped) if (!snapByPath[p]) delete byPath[p];
811
826
  const order = [...(snapshot?.order ?? [])];
812
827
  for (const p of Object.keys(localDocs)) {
813
828
  if (!snapByPath[p] && !dropped.has(p)) order.push(p);
@@ -997,14 +1012,16 @@ window.__ModuleLoader__.load({
997
1012
  return { text: stripInlineForExport(node.topic), lines: null };
998
1013
  }
999
1014
 
1000
- /** 019 盒尺寸估算:文本按折行行数生长;表格按行列数算网格尺寸。 */
1015
+ /** 019 盒尺寸估算:文本按折行行数生长;表格按行列数算网格尺寸。
1016
+ * 022:测量与渲染必须共用钳制后的列宽——先算盒宽再按 w/cols 折行,
1017
+ * 否则宽表(钳到 tableMaxW)按 110px 估行、渲染按更窄列宽折行,盒高不足。 */
1001
1018
  function measureExportBox(node) {
1002
1019
  if (node.kind === "table") {
1003
1020
  const rows = (node.data && node.data.rows) || [];
1004
1021
  const cols = rows.reduce((mx, row) => Math.max(mx, row.length), 0) || 1;
1005
- const cellInner = EXPORT.tableCellW - EXPORT.tableCellPad * 2;
1006
- const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
1007
1022
  const w = Math.min(EXPORT.tableMaxW, Math.max(EXPORT.tableMinW, cols * EXPORT.tableCellW));
1023
+ const cellInner = w / cols - EXPORT.tableCellPad * 2;
1024
+ const rowLines = rows.map((row) => row.reduce((mx, cell) => Math.max(mx, wrapExportText(stripInlineForExport(cell), cellInner, EXPORT.fontSize - 1).length), 1));
1008
1025
  const h = Math.max(EXPORT.lineHeight, rowLines.reduce((a, b) => a + b, 0) * EXPORT.lineHeight);
1009
1026
  return { w, h };
1010
1027
  }
@@ -1541,18 +1558,25 @@ window.__ModuleLoader__.load({
1541
1558
  //#region 019 血肉渲染:行内格式 + 大一统链接 + 表格块(规范源:003)
1542
1559
  // 行内格式统一扫描序:图片/链接 → 行内代码 → 粗体 → 删除线 → 斜体 → 裸链接。
1543
1560
  // 先命中先生效,裸链接放最后,避免吞掉已被 [文字](url) 消费的 URL。
1544
- const INLINE_PATTERN = /(!?\[[^\]]*\]\([^)]*\))|(`[^`]+`)|(\*\*[^*]+\*\*)|(~~[^~]+~~)|(\*[^*\s][^*]*\*)|(https?:\/\/[^\s)]+)/g;
1561
+ // 裸链接字符类排除 CJK 标点与全角符号(,。、;()……),
1562
+ // 否则中文句读被吞进 URL;ASCII 括号放行,由配平裁剪兜底。
1563
+ const INLINE_PATTERN = /(!?\[[^\]]*\]\([^)]*\))|(`[^`]+`)|(\*\*[^*]+\*\*)|(~~[^~]+~~)|(\*[^*\s][^*]*\*)|(https?:\/\/[^\s\u3000-\u303f\uff00-\uffef]+)/g;
1545
1564
 
1546
1565
  /** 大一统链接点击:在机器浏览器打开(新标签页),不触发画布聚焦缩放。 */
1547
1566
  function openLink(event, url) {
1548
- event.preventDefault();
1549
1567
  event.stopPropagation();
1568
+ // 只在 window.open 成功后 preventDefault:宿主拦截(返回 null 或
1569
+ // 抛错)时不拦,锚点自带的 target=_blank 原生导航接管——链接永远可达。
1570
+ // (旧版 catch 里给只读属性 defaultPrevented 赋值是死代码,拦了默认
1571
+ // 行为又开不了窗,链接彻底点不开。)
1572
+ let opened = null;
1550
1573
  try {
1551
- window.open(url, "_blank", "noopener");
1574
+ opened = window.open(url, "_blank", "noopener");
1552
1575
  } catch {
1553
- // 宿主环境拦截时退化为浏览器默认行为(不静默吞链接)。
1554
- event.defaultPrevented = false;
1576
+ opened = null;
1555
1577
  }
1578
+ if (!opened) return;
1579
+ event.preventDefault();
1556
1580
  }
1557
1581
 
1558
1582
  /**
@@ -1575,20 +1599,26 @@ window.__ModuleLoader__.load({
1575
1599
  // [文字](url) 或 ![alt](url)。图片块暂缓(003 §9):图语法退化为
1576
1600
  // 指向原图的链接,同时把 alt 与原图地址都完整呈现(不缩减)。
1577
1601
  const parsed = /^(!?)\[([^\]]*)\]\(([^)]*)\)$/.exec(token);
1578
- // 普通链接标签取文字(无文字显地址);图语法带 alt 时两者都完整呈现。
1579
- const label = parsed[1]
1580
- ? (parsed[2] ? `${parsed[2]} (${parsed[3]})` : parsed[3])
1581
- : (parsed[2] || parsed[3]);
1582
- out.push((0, react_jsx_runtime.jsx)("a", {
1583
- key,
1584
- href: parsed[3],
1585
- target: "_blank",
1586
- rel: "noopener noreferrer",
1587
- style: S.inlineLink,
1588
- title: parsed[3],
1589
- onClick: (e) => openLink(e, parsed[3]),
1590
- children: label,
1591
- }, key));
1602
+ // scheme 白名单:只放行 http/https/mailto。javascript:/data:
1603
+ // 等不进 href,整串原样退化为纯文本(不缩减,也不可执行)。
1604
+ if (!/^\s*(https?:|mailto:)/i.test(parsed[3])) {
1605
+ out.push(token);
1606
+ } else {
1607
+ // 普通链接标签取文字(无文字显地址);图语法带 alt 时两者都完整呈现。
1608
+ const label = parsed[1]
1609
+ ? (parsed[2] ? `${parsed[2]} (${parsed[3]})` : parsed[3])
1610
+ : (parsed[2] || parsed[3]);
1611
+ out.push((0, react_jsx_runtime.jsx)("a", {
1612
+ key,
1613
+ href: parsed[3],
1614
+ target: "_blank",
1615
+ rel: "noopener noreferrer",
1616
+ style: S.inlineLink,
1617
+ title: parsed[3],
1618
+ onClick: (e) => openLink(e, parsed[3]),
1619
+ children: label,
1620
+ }, key));
1621
+ }
1592
1622
  } else if (m[2]) {
1593
1623
  out.push((0, react_jsx_runtime.jsx)("code", { key, style: S.inlineCode, children: token.slice(1, -1) }, key));
1594
1624
  } else if (m[3]) {
@@ -1598,17 +1628,30 @@ window.__ModuleLoader__.load({
1598
1628
  } else if (m[5]) {
1599
1629
  out.push((0, react_jsx_runtime.jsx)("em", { key, children: token.slice(1, -1) }, key));
1600
1630
  } else {
1601
- // 裸链接:完整显示、可点击。
1631
+ // 裸链接:完整显示、可点击。维基式配平括号属于 URL;未配平的
1632
+ // 尾 ) 退回正文当纯文本——href 干净,可见文本不丢字符。
1633
+ let url = token;
1634
+ let opens = 0;
1635
+ let closes = 0;
1636
+ for (const ch of url) {
1637
+ if (ch === "(") opens += 1;
1638
+ else if (ch === ")") closes += 1;
1639
+ }
1640
+ while (closes > opens && url.endsWith(")")) {
1641
+ url = url.slice(0, -1);
1642
+ closes -= 1;
1643
+ }
1602
1644
  out.push((0, react_jsx_runtime.jsx)("a", {
1603
1645
  key,
1604
- href: token,
1646
+ href: url,
1605
1647
  target: "_blank",
1606
1648
  rel: "noopener noreferrer",
1607
1649
  style: S.inlineLink,
1608
- title: token,
1609
- onClick: (e) => openLink(e, token),
1610
- children: token,
1650
+ title: url,
1651
+ onClick: (e) => openLink(e, url),
1652
+ children: url,
1611
1653
  }, key));
1654
+ if (url.length < token.length) out.push(token.slice(url.length));
1612
1655
  }
1613
1656
  last = m.index + token.length;
1614
1657
  }
@@ -3352,6 +3395,8 @@ window.__ModuleLoader__.load({
3352
3395
  parseTableRow,
3353
3396
  nodeFullText,
3354
3397
  renderInline,
3398
+ // 链接点击:供测试验证开窗成功才拦默认行为(宿主拦截时退回原生导航)。
3399
+ openLink,
3355
3400
  stripInlineForExport,
3356
3401
  wrapExportText,
3357
3402
  COLOR_THEMES,
package/index.js CHANGED
@@ -9,12 +9,12 @@
9
9
  // - 四工具都带 path/name 参数(决策 3:多脑图并存,作用于指定那颗)。
10
10
  // - 结果 JSON {ok, op, path, rootTitle, content, renamedFrom?}:content 全文
11
11
  // 供模型续编辑,client 用同一份重放面板(工具结果即实时通道,002 第二节)。
12
- // - requireApproval 配置(决策 6):默认 false 免审批;置 true 时 mindmap_update
13
- // 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。015 起经 settings
14
- // namespace 可在设置面板运行时切换(见 SETTINGS_NAMESPACE/Config)。
12
+ // - requireApproval 配置(决策 6):默认 false 免审批;置 true 时 mindmap_create
13
+ // 与 mindmap_update 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。
14
+ // 015 起经 settings namespace 可在设置面板运行时切换(见 SETTINGS_NAMESPACE/Config)。
15
15
  // - 依赖:仅 @deepseek-ai/schemastery(settings schema;发布包正常解析,
16
16
  // link 开发需先 npm i)。工具参数 schema 仍手写 JSON Schema(003 偏差 1)。
17
- import { access, opendir, readFile, rename, stat, writeFile } from 'node:fs/promises'
17
+ import { access, opendir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
18
18
  import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'
19
19
  import Schema from '@deepseek-ai/schemastery'
20
20
 
@@ -26,7 +26,7 @@ export const inject = ['tools', 'systemPrompt', 'webServer', 'sessions']
26
26
  // defaultPanelWidth 供客户端面板取默认宽度(20-80 钳制由客户端执行)。
27
27
  export const SETTINGS_NAMESPACE = 'mindmap'
28
28
  export const Config = Schema.object({
29
- requireApproval: Schema.boolean().default(false).description('Require native DSH approval for every mindmap_update (including renameRoot). Files are git-managed, so it defaults to off. Hidden from the settings panel; still honored at runtime.'),
29
+ requireApproval: Schema.boolean().default(false).description('Require native DSH approval for every mindmap_create and mindmap_update (including renameRoot). Files are git-managed, so it defaults to off. Hidden from the settings panel; still honored at runtime.'),
30
30
  defaultPanelWidth: Schema.number().default(42).description('Default floating-panel width as a percentage of the viewport (clamped 20-80 on the client).'),
31
31
  lineStyle: Schema.union(['curve', 'elbow']).default('elbow').description('Connector line style between nodes: curve (bezier) or elbow (orthogonal).'),
32
32
  cardStyle: Schema.union(['rounded', 'square']).default('rounded').description('Node card corner style.'),
@@ -113,15 +113,44 @@ function escapesBase(rel) {
113
113
  return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)
114
114
  }
115
115
 
116
- /** 请求路径校验:缺省 = 根 cwd;显式路径必须绝对且落在 cwd 内。 */
117
- function resolveTreePath(cwd, input) {
116
+ /**
117
+ * realpath 包含性校验(#5):字符串规范化只挡字面 `..`,cwd 内指向外部
118
+ * 的符号链接能骗过它。尾部不存在的段(待建文件)向上走最近的存在祖先
119
+ * 逐个 realpath——符号链接只能藏在已存在的段里。解析后仍在 base 内返回
120
+ * true;base 自身不存在或越界返回 false。
121
+ */
122
+ async function resolvesInsideBase(resolved, base) {
123
+ let realBase
124
+ try {
125
+ realBase = await realpath(base)
126
+ } catch {
127
+ return false
128
+ }
129
+ let probe = resolved
130
+ for (;;) {
131
+ try {
132
+ const real = await realpath(probe)
133
+ if (real === realBase) return true
134
+ return !escapesBase(relative(realBase, real))
135
+ } catch (error) {
136
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error
137
+ const parent = dirname(probe)
138
+ if (parent === probe) return false
139
+ probe = parent
140
+ }
141
+ }
142
+ }
143
+
144
+ /** 请求路径校验:缺省 = 根 cwd;显式路径必须绝对、落在 cwd 内,
145
+ * 且解析符号链接后仍在内(#5)。 */
146
+ async function resolveTreePath(cwd, input) {
118
147
  if (!cwd) throw httpError(400, 'no-cwd', 'session has no working directory')
119
148
  if (input === undefined || input === null || String(input).trim() === '') return cwd
120
149
  const p = String(input).trim()
121
150
  if (!isAbsolute(p)) throw httpError(400, 'bad-request', `path must be absolute: ${JSON.stringify(p)}`)
122
151
  const resolved = resolvePath(p)
123
152
  const rel = relative(cwd, resolved)
124
- if (escapesBase(rel)) {
153
+ if (escapesBase(rel) || !(await resolvesInsideBase(resolved, cwd))) {
125
154
  throw httpError(400, 'bad-request', `path must stay inside the session working directory (${cwd})`)
126
155
  }
127
156
  return resolved
@@ -140,8 +169,10 @@ async function listDirectoryLevel(path, maxEntries = MAX_TREE_ENTRIES) {
140
169
  try {
141
170
  for await (const dirent of dir) {
142
171
  if (rows.length >= maxEntries) {
143
- overflow += 1
144
- continue
172
+ // 022:到达上限即停(旧实现 continue 会把巨型目录整个遍历一遍)。
173
+ // truncated 只取布尔语义,无需精确计数剩余条目。
174
+ overflow = 1
175
+ break
145
176
  }
146
177
  rows.push({
147
178
  name: dirent.name,
@@ -204,7 +235,7 @@ function sanitizeStem(input) {
204
235
  * 且必须以 .md 结尾。cwd 缺失时仅接受绝对路径。
205
236
  * @returns 绝对规范化路径。
206
237
  */
207
- function resolveMindmapPath(cwd, input) {
238
+ async function resolveMindmapPath(cwd, input) {
208
239
  if (typeof input !== 'string' || !input.trim()) throw new Error('path is required.')
209
240
  const p = input.trim()
210
241
  if (!/\.md$/i.test(p)) throw new Error(`mindmap path must end with .md: ${JSON.stringify(p)}.`)
@@ -214,7 +245,8 @@ function resolveMindmapPath(cwd, input) {
214
245
  }
215
246
  const resolved = resolvePath(cwd, p)
216
247
  const rel = relative(cwd, resolved)
217
- if (rel === '' || escapesBase(rel)) {
248
+ // realpath 兜底(#5):写路径经符号链接越狱是安全敏感操作。
249
+ if (rel === '' || escapesBase(rel) || !(await resolvesInsideBase(resolved, cwd))) {
218
250
  throw new Error(`mindmap path must stay inside the session working directory (${cwd}).`)
219
251
  }
220
252
  return resolved
@@ -288,10 +320,18 @@ export function apply(ctx, config = {}) {
288
320
 
289
321
  // 后悔药开关(决策 6):钩子常驻注册,运行时读 activeConfig().requireApproval
290
322
  // ——设置面板切换立即生效;关闭时直接放行(默认 false 免审批)。
323
+ // 022:create 同为写路径,一并纳入审批(CONTRIBUTING:所有写路径安全敏感)。
291
324
  ctx.on('tools/pre-execute', async (exec, next) => {
292
325
  const decision = await next()
293
326
  if (decision.kind !== 'allow') return decision
294
327
  if (!activeConfig().requireApproval) return decision
328
+ if (exec.name === 'mindmap_create') {
329
+ const args = exec.arguments ?? {}
330
+ return {
331
+ kind: 'ask',
332
+ reason: `Create mindmap ${JSON.stringify(String(args.name ?? '?'))}. dsh-mindmap is configured with requireApproval.`,
333
+ }
334
+ }
295
335
  if (exec.name !== 'mindmap_update') return decision
296
336
  const args = exec.arguments ?? {}
297
337
  const renameNote = typeof args.renameRoot === 'string' && args.renameRoot ? `, rename root to "${args.renameRoot}"` : ''
@@ -318,7 +358,7 @@ export function apply(ctx, config = {}) {
318
358
  const cwd = sessionCwd(exec)
319
359
  if (!cwd) throw new Error('The session has no working directory; cannot create a mindmap.')
320
360
  const stem = sanitizeStem(args?.name)
321
- const path = resolveMindmapPath(cwd, `${stem}.md`)
361
+ const path = await resolveMindmapPath(cwd, `${stem}.md`)
322
362
  // wx = 不存在才创建:原子拒绝已存在(含并发竞态)与同名目录,无 TOCTOU 窗口。
323
363
  try {
324
364
  await writeFile(path, '', { encoding: 'utf8', flag: 'wx' })
@@ -345,7 +385,7 @@ export function apply(ctx, config = {}) {
345
385
  output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
346
386
  timeoutMs: TOOL_TIMEOUT_MS,
347
387
  async execute(args, exec) {
348
- const path = resolveMindmapPath(sessionCwd(exec), args?.path)
388
+ const path = await resolveMindmapPath(sessionCwd(exec), args?.path)
349
389
  const content = await readFile(path, 'utf8')
350
390
  return buildResult('open', path, { content })
351
391
  },
@@ -364,7 +404,7 @@ export function apply(ctx, config = {}) {
364
404
  output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
365
405
  timeoutMs: TOOL_TIMEOUT_MS,
366
406
  async execute(args, exec) {
367
- const path = resolveMindmapPath(sessionCwd(exec), args?.path)
407
+ const path = await resolveMindmapPath(sessionCwd(exec), args?.path)
368
408
  const content = await readFile(path, 'utf8')
369
409
  return buildResult('get', path, { content })
370
410
  },
@@ -386,7 +426,7 @@ export function apply(ctx, config = {}) {
386
426
  timeoutMs: TOOL_TIMEOUT_MS,
387
427
  async execute(args, exec) {
388
428
  const cwd = sessionCwd(exec)
389
- const path = resolveMindmapPath(cwd, args?.path)
429
+ const path = await resolveMindmapPath(cwd, args?.path)
390
430
  const hasContent = typeof args?.content === 'string'
391
431
  if (!hasContent && typeof args?.renameRoot !== 'string') {
392
432
  throw new Error('mindmap_update requires content (or renameRoot alone for a pure rename).')
@@ -453,7 +493,7 @@ export function apply(ctx, config = {}) {
453
493
  sendJson(res, 400, { ok: false, error: { code: 'no-cwd', message: 'session has no working directory' } })
454
494
  return
455
495
  }
456
- const dir = resolveTreePath(cwd, payload.path)
496
+ const dir = await resolveTreePath(cwd, payload.path)
457
497
  const listing = await listDirectoryLevel(dir)
458
498
  sendJson(res, 200, { ok: true, value: { ...listing, cwd } })
459
499
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mindmap",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Mindmap plugin for DeepSeek Harness: a plain markdown file in the working directory IS the mindmap; the chat edits it step by step and the right-side panel follows live.",
5
5
  "repository": {
6
6
  "type": "git",