From 9eb7b2ae7a67055ffcccdb205063d4221b82188c Mon Sep 17 00:00:00 2001 From: Nicholas023 Date: Tue, 20 Oct 2026 14:12:15 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(#21):=20js-yaml=20=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=BE=93=E5=87=BA=E7=9C=9F=E5=AE=9E=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=8E=9F=E5=9B=A0=E4=BE=9B=E8=AF=8A=E6=96=AD=EF=BC=88?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E8=BF=94=E5=9B=9E=20null=20=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E4=B8=8D=E5=8F=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/model-provider.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/model-provider.js b/src/model-provider.js index ff0692c..ebbfb7e 100644 --- a/src/model-provider.js +++ b/src/model-provider.js @@ -64,8 +64,14 @@ function loadProfileLlmConfig() { let yaml; try { yaml = require('js-yaml'); - } catch { - return null; // 开发环境若 junction 未建也能继续(路由表可能为空,resolveProvider 返回 null) + } catch (error) { + // issue #21:依赖损坏/缺失被静默吞掉(catch 空 → 路由表为空、评审 0.0s + // 失败且失败文案不指向原因)。这里输出真实错误原因供诊断,但**保持原返回 + // 契约不变**:解析失败 = 返回 null 不抛(scripts/switch-model-e2e.mjs 走 + // check('解析出端点', !!ep) 安全链,运行态降级链同样依赖「解析失败=返回 + // null 不抛」)。js-yaml 本体不涉及敏感信息,可安全打印错误消息。 + console.error('[dsh-code-review] 加载 js-yaml 失败,评审模型路由表将为空(解析失败返回 null 语义不变):', error?.message ?? error); + return null; } // 根目录统一走 resolveDshRoot:优先 DSH_HOME,回退 ~/.dsh(issue #13)。 const profilesRoot = join(resolveDshRoot(), 'profiles'); From 2697e26d0ad2c34522d1f09d7746507ddb8c9d36 Mon Sep 17 00:00:00 2001 From: Nicholas023 Date: Tue, 20 Oct 2026 14:12:15 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(#22):=20=E5=BC=95=E6=93=8E=20e2e=20key?= =?UTF-8?q?=20=E7=BC=BA=E5=A4=B1=E7=9F=AD=E8=B7=AF=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=EF=BC=9Bsensitive=20e2e=20=E6=96=AD=E8=A8=80=E6=8F=90=E5=87=BA?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E6=9D=9C=E7=BB=9D=E8=AF=84=E5=AE=A1=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=9D=99=E9=BB=98=E5=81=87=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/engine-e2e-smoke.mjs | 6 ++++++ scripts/sensitive-e2e-smoke.mjs | 24 +++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/scripts/engine-e2e-smoke.mjs b/scripts/engine-e2e-smoke.mjs index e3dab4d..1f5c11b 100644 --- a/scripts/engine-e2e-smoke.mjs +++ b/scripts/engine-e2e-smoke.mjs @@ -111,6 +111,12 @@ try { // 提示不再硬编码 codecheck.exe——平台化路径由 // BIN 计算得出,提示直接引用它(Linux/macOS 上出现 .exe 字面会误导)。 check(`引擎存在(${BIN})`, false, `请先把引擎二进制放到插件包 bin/(预期 ${BIN})`); + } else if (typeof key !== 'string' || key.length < 8) { + // issue #22:key 未解析(undefined)时仍构造 OCR_LLM_TOKEN: key(即成 + // 字面量 'undefined')并 spawn 引擎空等 180s 必败。key 缺失 = 无真实端点 + // 凭证,引擎评审无从发起——短路跳过引擎调用,明确输出跳过原因(无 + // DEEPSEEK_API_KEY 的开发机/CI 不应以「空等超时」形态误报失败)。 + check('跳过引擎调用(key 缺失)', true, '未解析到 DEEPSEEK_API_KEY(真实端点 token),引擎无法评审;配置 ~/.dsh/.credentials.yaml 后重跑'); } else { const env = { ...process.env, diff --git a/scripts/sensitive-e2e-smoke.mjs b/scripts/sensitive-e2e-smoke.mjs index d55aad9..f061066 100644 --- a/scripts/sensitive-e2e-smoke.mjs +++ b/scripts/sensitive-e2e-smoke.mjs @@ -102,21 +102,27 @@ try { }; if (existsSync(reportDir)) walk(reportDir); check('报告文件已落盘(按日期/仓库分目录)', candidates.length >= 1, candidates[0] ?? '无'); + // issue #22:此前这些断言整体嵌套在 if (candidates.length > 0) 内——评审 + // 静默失败(未落盘/落盘目录异常)时整段跳过、零失败项假绿。把「报告存在」 + // 抽成独立判据后,泄露检测断言无条件执行:评审失败本身也被记录,杜绝静默跳过。 + check('报告文件已生成(评审链路未静默失败)', candidates.length > 0, candidates[0] ?? '无报告落盘'); if (candidates.length > 0) { reportPath = candidates[0]; const report = readFileSync(reportPath, 'utf8'); check('报告含「敏感文件点名」节', report.includes('敏感文件点名'), '命中' ); check('报告点名 .env(内容未送审的兜底人眼检查点)', report.includes('.env'), '点名项出现'); - // 内容不送审:报告正文不得出现 .env 内容里的占位值。 - // 断言值必须与上方写入 .env 的夹具**同一串**(改夹具不改断言 = 断言恒真假绿)。 - const envProbes = ['EXAMPLE_PLACEHOLDER', 'EXAMPLE_NOT_A_REAL_KEY_12345']; - const leaked = envProbes.filter((s) => report.includes(s)); - check('报告未含 .env 内容(密钥值不出现 = 内容未送审)', leaked.length === 0, leaked.join(',') || '无泄露'); - // 引擎评审输出同样不得含密钥值(送审对象只有 app.py diff)。 - const engineOut = text; - const leakedOut = envProbes.filter((s) => engineOut.includes(s)); - check('引擎输出未含 .env 内容(送审链路也未触碰)', leakedOut.length === 0, leakedOut.join(',') || '无泄露'); } + // 内容不送审断言:对报告与引擎输出各验一次「占位值不出现」。即使报告未落盘 + // (candidates=0)也照常执行——泄露检测不依赖报告存在,评审失败被上一判据记录。 + // 断言值必须与上方写入 .env 的夹具**同一串**(改夹具不改断言 = 断言恒真假绿)。 + const envProbes = ['EXAMPLE_PLACEHOLDER', 'EXAMPLE_NOT_A_REAL_KEY_12345']; + const leaked = reportPath && existsSync(reportPath) + ? envProbes.filter((s) => readFileSync(reportPath, 'utf8').includes(s)) + : []; + check('报告未含 .env 内容(密钥值不出现 = 内容未送审)', leaked.length === 0, leaked.join(',') || '无泄露'); + const engineOut = text; + const leakedOut = envProbes.filter((s) => engineOut.includes(s)); + check('引擎输出未含 .env 内容(送审链路也未触碰)', leakedOut.length === 0, leakedOut.join(',') || '无泄露'); } finally { rmSync(work, { recursive: true, force: true }); console.log('临时仓库/报告目录已清理。'); From 7b5a1ebe912e2bda6ce2dba339d39d9ad02de8f8 Mon Sep 17 00:00:00 2001 From: Nicholas023 Date: Tue, 20 Oct 2026 14:12:16 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(#23):=20dsh-plugin.json=20=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=A3=B0=E6=98=8E=E8=A1=A5=E5=AE=89=E5=85=A8=E6=89=BF?= =?UTF-8?q?=E8=AF=BA=EF=BC=9Bset-config-tool=20=E7=BB=99=20report=5Fdir=20?= =?UTF-8?q?=E5=8A=A0=20home=20=E5=86=85=E7=BB=9D=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84+=E7=AC=A6=E5=8F=B7=E9=93=BE=E6=8E=A5=E9=80=83?= =?UTF-8?q?=E9=80=B8=E5=86=99=E5=89=8D=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh-plugin.json | 6 ++-- src/set-config-tool.js | 72 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/dsh-plugin.json b/dsh-plugin.json index 1ed878e..0384833 100644 --- a/dsh-plugin.json +++ b/dsh-plugin.json @@ -16,18 +16,18 @@ "permissions": [ { "name": "fs.write", - "scope": "~/.dsh/dsh-code-review/**、默认报告目录(~ 展开后的 ~/dsh-code-review-reports/**)及用户经 report_dir 配置的报告目录", + "scope": "~/.dsh/dsh-code-review/**、默认报告目录(~ 展开后的 ~/dsh-code-review-reports/**)及用户经 report_dir 配置的报告目录(写入前校验:目标须为 home 下绝对路径、解析符号链接后无目录遍历逃逸)", "reason": "落盘任务簿(tasks/ 任务文件)/ 插件运行配置(plugin-config.json)/ 评审报告留档" }, { "name": "process.spawn", - "scope": "插件包 bin/ 或 PATH 中的 codecheck(含用户经 engine_bin 显式配置、写入前做本地文件校验的路径);辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程", + "scope": "插件包 bin/ 或 PATH 中的 codecheck(用户经 engine_bin 显式配置时:写入前校验 engine_bin 须为绝对路径、文件存在、非符号链接、非 setuid 位);辅助命令 git / where|which / taskkill(平台子进程树终止);以及以 process.execPath 拉起的本插件后台 serve 进程", "reason": "评审引擎与后台 serve 均为外部子进程(由 runner / task-book 以子进程方式拉起执行),辅助命令服务于待提交清单(git)、引擎路径探测(where/which)与超时终止(taskkill)" }, { "name": "credentials.read", "scope": "~/.dsh/.credentials.yaml 与环境变量中的模型凭据引用", - "reason": "调用评审 LLM 所需的 API key,复用宿主既有凭据通道" + "reason": "调用评审 LLM 所需的 API key,复用宿主既有凭据通道;凭据仅用于 API 调用,绝不写入日志或评审报告" } ], "contributes": { diff --git a/src/set-config-tool.js b/src/set-config-tool.js index 7a1a525..9024c59 100644 --- a/src/set-config-tool.js +++ b/src/set-config-tool.js @@ -22,8 +22,8 @@ * @module dsh-code-review/set-config-tool */ import { writeFileConfig, PLUGIN_CONFIG_FILE } from './file-config.js'; -import { existsSync, statSync, accessSync, constants } from 'node:fs'; -import { resolve, join } from 'node:path'; +import { existsSync, statSync, accessSync, realpathSync, constants } from 'node:fs'; +import { resolve, join, dirname, sep } from 'node:path'; import { homedir } from 'node:os'; import { cwdOfExec } from './exec-context.js'; @@ -68,7 +68,7 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { additionalProperties: false, properties: { review_model: { type: ['string', 'null'], description: '评审模型标识。省略/空串/null = 沿用会话模型;其他值 = 显式另选的模型。' }, - report_dir: { type: ['string', 'null'], description: '报告目录(支持 ~)。空串/null 恢复默认。' }, + report_dir: { type: ['string', 'null'], description: '报告目录(支持 ~,须为 home 下绝对路径,写入前校验防符号链接逃逸)。空串/null 恢复默认。' }, engine_bin: { type: ['string', 'null'], description: '引擎二进制路径。空串/null 恢复自动探测。传路径前请先确认文件存在——不存在/目录会被拒绝写入。' }, }, required: [], @@ -126,6 +126,59 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { ...(args?.report_dir !== undefined ? { report_dir: normalizeValue(args.report_dir) } : {}), ...(args?.engine_bin !== undefined ? { engine_bin: normalizeValue(args.engine_bin) } : {}), }; + // report_dir 安全校验(issue #23 代码级兑现):dsh-plugin.json 的 + // fs.write 声明已承诺「写入前校验目标为 home 下绝对路径、解析符号链接后 + // 无目录遍历逃逸」——此处把承诺变成代码。与 engine_bin 校验的差异:报告 + // 目录消费端(report.js writeReport)会自动 mkdir,故「目标目录尚不存在」 + // 是合法输入(对最近存在的祖先做 realpath 防符号链接逃逸即可); + // null/空串(清空恢复默认)跳过校验。有意收紧:报告目录仅允许落在用户 + // home 之内(默认 ~/dsh-code-review-reports 即在此),保证 manifest + // 声明的权限面与实际写盘点一致。 + let reportDirRejected = null; + if (patch.report_dir !== null && patch.report_dir !== undefined) { + let candidate; + try { + if (patch.report_dir === '~') { + candidate = homedir(); + } else if (patch.report_dir.startsWith('~/') || patch.report_dir.startsWith('~\\')) { + candidate = join(homedir(), patch.report_dir.slice(2)); + } else { + // 相对路径以**调用方会话目录**为基准解析(与 engine_bin 同一基准)。 + candidate = resolve(cwdOfExec(_exec) ?? process.cwd(), patch.report_dir); + } + // 承诺一:home 下绝对路径。home 前缀比较必须带分隔符(sep)—— + // C:/Users/HP 与 C:/Users/HP2 不混淆;Windows 上大小写不敏感, + // 统一 resolve 规范化后再比。 + const home = resolve(homedir()); + if (candidate !== home && !candidate.startsWith(home + sep)) { + reportDirRejected = `report_dir 校验失败:\`${patch.report_dir}\` 解析为 \`${candidate}\`,不在用户 home 目录(\`${home}\`)之下(报告目录仅允许落在 home 内,传空串可恢复默认)。`; + } else { + // 承诺二:解析符号链接后无目录遍历逃逸。目标可能尚未创建 + //(writeReport 自动 mkdir),逐级上溯到最近存在的祖先做 realpath。 + let probe = candidate; + while (!existsSync(probe)) { + const parent = dirname(probe); + if (parent === probe) break; + probe = parent; + } + const real = realpathSync(probe); + if (real !== home && !real.startsWith(home + sep)) { + reportDirRejected = `report_dir 校验失败:\`${patch.report_dir}\` 经符号链接解析后指向 \`${real}\`,逃逸出用户 home 目录(\`${home}\`)。`; + } else if (existsSync(candidate) && !statSync(candidate).isDirectory()) { + reportDirRejected = `report_dir 校验失败:\`${candidate}\` 存在但不是目录(报告目录必须是目录)。`; + } + } + } catch (error) { + reportDirRejected = `report_dir 校验异常:${String(error?.message ?? error)}(该字段未写入,其余字段不受影响)`; + } + if (reportDirRejected !== null) { + delete patch.report_dir; + } else { + // 落盘规范化绝对路径——消费端 expandHome 对绝对路径幂等(report.js), + // 保存展开结果避免「校验看到的路径 ≠ 运行时路径」错位。 + patch.report_dir = candidate; + } + } // engine_bin 安全校验:该值原样落盘后会被引擎定位 // 逻辑作为 spawn 候选二进制(cfg.engine_bin 优先级最高),且支持相对 // 路径/`~`——恶意输入(提示注入写 /tmp/evil、../../foo)即可把后续评审 @@ -199,12 +252,16 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { patch.engine_bin = candidate; } } - // 整单只剩被拒的 engine_bin(其余字段时照写,拒绝原因进回显)。 - if (Object.keys(patch).length === 0 && engineBinRejected !== null) { + // 整单只剩被拒的 engine_bin / report_dir(其余字段时照写,拒绝原因进回显)。 + if (Object.keys(patch).length === 0 && (engineBinRejected !== null || reportDirRejected !== null)) { + const reasons = [engineBinRejected, reportDirRejected] + .filter((x) => x !== null) + .map((x) => `- ${x}`) + .join('\n'); return { ok: false, config_path: configFile, - text: `## 代码审查插件配置(写入被拒)\n\n- ${engineBinRejected}\n- 已拒绝写入,现有配置未改动。`, + text: `## 代码审查插件配置(写入被拒)\n\n${reasons}\n- 已拒绝写入,现有配置未改动。`, }; } let wrote = false; @@ -233,10 +290,11 @@ export function createSetConfigTool(ctx, getConfig, io = {}) { `- 评审模型:\`${active.review_model}\``, `- 报告目录:\`${active.report_dir}\``, `- 引擎路径:\`${active.engine_bin || '(自动探测)'}\``, - // 部分字段被拒(engine_bin 校验失败)时 + // 部分字段被拒(engine_bin / report_dir 校验失败)时 // 成功回显必须带拒绝说明——「写入成功」只是其余字段成功,被拒字段 // 不可静默消失。 ...(engineBinRejected !== null ? ['', `- ⚠ ${engineBinRejected}(该字段未写入,其余字段已生效)`] : []), + ...(reportDirRejected !== null ? ['', `- ⚠ ${reportDirRejected}(该字段未写入,其余字段已生效)`] : []), '- 提示:面板「设置」里的同名字段在 0.2.0 宿主对第三方插件的回传通道修复前暂不生效,以本工具/本配置文件为准。', ]; return { ok: true, config_path: configFile, text: lines.join('\n') }; From f7d8aef5af9c881adf5cd082f5bef77318833fbb Mon Sep 17 00:00:00 2001 From: Nicholas023 Date: Tue, 20 Oct 2026 14:12:16 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(#24):=20=E6=95=8F=E6=84=9F=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E6=94=B6=E7=B4=A7=E2=80=94=E2=80=94token=20=E8=AF=8D?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E3=80=81hidden-secret=20=E4=B8=8E=20secrets/?= =?UTF-8?q?credentials=20=E5=BD=92=E4=B8=80=E3=80=81=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=90=8D=E9=94=9A=E5=AE=9A=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E3=80=81=E6=96=87=E4=BB=B6=E5=90=8D=E6=B8=85=E6=B4=97=E8=A1=A5?= =?UTF-8?q?=20~#&?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sensitive-list.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/sensitive-list.js b/src/sensitive-list.js index 9f96c47..455a166 100644 --- a/src/sensitive-list.js +++ b/src/sensitive-list.js @@ -28,7 +28,12 @@ export const SENSITIVE_PATH_PATTERNS = [ // 下方 id_* 行覆盖、*.pem 由凭据扩展名行覆盖);.ssh 目录段单独无覆盖属 // 已知盲区(引擎侧路径保护另有 .ssh 排除),勿据此删除下方覆盖行。 /(^|\/)\.[^/]*key[^/]*$/i, - /(^|\/)\.[^/]*secret/i, + // 隐藏文件/目录(点开头)中 secret 作为**独立词段**出现(.secret、.secrets.yml、 + // .my-secrets、dir/.secrets)。与下方 secrets?|credentials? 行分工:该行只管点 + // 开头形态;非隐藏的 secrets/credentials.yml 等由 secrets?|credentials? 行覆盖 + // (两个正则不再对同一形态重叠发力)。末尾 (?=[.\-_]|$) 保证 secret 词段后有 + // 分隔符或行尾——隐藏词段内嵌 secret 子串的其他形态不再被宽泛 [^/]* 吞入。 + /(^|\/)\.([^/]*[.\-_])?secrets?(?=[.\-_]|$)/i, /(^|\/)\.env(\.[^/]+)?$/i, // .env / .env.production 等 // 凭据扩展名必须作为「独立扩展名」出现(my.key、cert.pfx、server.crt.bak 等): // 敏感词前必须由点号分隔([^/]*\.),后跟行尾或再一个点(如 .key.bak 的 .bak)。 @@ -37,7 +42,11 @@ export const SENSITIVE_PATH_PATTERNS = [ // 敏感文件,噪声稀释真正凭据命中的信号(issue #11)。修后路径段内嵌的 // key/cert/pem(sessionkey.go、keycmd.go 等)也不再命中;id_rsa/credentials/.env // 等由各自独立规则覆盖,不受此行影响。 - /(^|\/)[^/]*\.(pem|p12|pfx|keystore|jks|cert|crt|key|kdbx|otpkey)(\.|$)/i, + // 凭据扩展名后允许再跟一段点号修饰(.key.bak 的 .bak)或直接行尾——与 .env + // 行的 (\.[^/]+)?$ 锚定形态对齐(issue #24:原 (\.|$) 只认「单个点或行尾」, + // 边界语义不一致;对齐后 .key.bak.bak 这类「凭据扩展名后仍是文件修饰」也按 + // 同口径处理)。 + /(^|\/)[^/]*\.(pem|p12|pfx|keystore|jks|cert|crt|key|kdbx|otpkey)(\.[^/]+)?$/i, /(^|\/)credentials[^/]*$/i, // token 模式收紧(形态:独立词段 token(s) + **凭据类扩展名或路径结尾**)—— // 原 `.*token[^/]*$` 命中路径任意位置含 "token" 的文件(src/tokenizer.js、 @@ -45,7 +54,11 @@ export const SENSITIVE_PATH_PATTERNS = [ // 扩展名收敛到凭据类白名单(与下方 token+分隔符规则同一白名单):token.json / // api_token / my-tokens 命中;token.js(令牌类型定义模块)、tokens.ts、 // design-tokens.md 等业务源码放出——避免把「代码文件」当凭据文件点名。 - /(^|\/)(.*[.\-_])?tokens?(\.(json|yml|yaml|txt|ini|conf|cfg|config|log|bak|old|tmp|env))?$/i, + // token 词段后必须跟随分隔符(. _ -)或行尾((?=[._-]|$))——issue #24: + // tokens? 缺词边界时,只要路径内存在 "token" 子串且其后以 .yml 等合法扩展名 + // 收尾即命中,tokenizer-decoder.yml / tokenizer.config 这类「token 只是词前缀」 + // 的业务命名会被误收(词边界挡住:token 后跟 "i" 不是分隔符也不是行尾)。 + /(^|\/)(.*[.\-_])?tokens?(?=[._-]|$)(\.(json|yml|yaml|txt|ini|conf|cfg|config|log|bak|old|tmp|env))?$/i, // token 后跟分隔符、且带凭据类扩展名的形态(refresh_token_backup.json、 // token_store.yaml、my_token.txt)——上一行的「token 作为独立词段 + 点号 // 扩展名或路径结尾」覆盖不到它们:token 后不是点而是下划线/横线。此处不写 @@ -117,7 +130,7 @@ export function buildSensitiveBlock(pendingPaths) { // 路径未经转义直接进反引号代码块——git 文件名可含 // 反引号/换行(git 允许),会闭合代码块并向报告注入任意 markdown。拼接前 // 剔除反引号与换行(点名目的是人眼确认路径,替换为空格不影响辨识)。 - const safe = String(h.path).replace(/[`\r\n*_[\]<>|]+/g, ' '); + const safe = String(h.path).replace(/[`\r\n*_~#&\[\]<>|]+/g, ' '); return `${i + 1}. \`${safe}\``; }); return [