dsh-redteam-report 0.2.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/LICENSE +21 -0
- package/README.md +94 -0
- package/WORKSPACE-REPORTS.md +48 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +747 -0
- package/lib/host.js +119 -0
- package/lib/parts/client.head.js +15 -0
- package/lib/parts/client.shim.js +45 -0
- package/lib/parts/client.tail.js +5 -0
- package/lib/parts/host.head.js +80 -0
- package/lib/parts/host.tail.js +37 -0
- package/package.json +68 -0
- package/src/client.js +602 -0
- package/src/docx.js +776 -0
- package/src/host.js +1176 -0
- package/src/workspace-client.js +108 -0
- package/src/workspace-evidence.js +310 -0
- package/src/workspace-install.js +60 -0
- package/src/workspace-runtime.js +409 -0
- package/tools/build-lib.mjs +120 -0
- package/tools/prepare-gh-packages.mjs +66 -0
package/lib/host.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// 常驻(静态)Host 半边。
|
|
2
|
+
//
|
|
3
|
+
// 正式入口调用 workspace-install:旧 src/host.js 作为源码数据交给工作区运行器,
|
|
4
|
+
// 每个工作区独立实例化,避免共享全局报告库。这里提供工具与 HTTP 的薄垫片:
|
|
5
|
+
//
|
|
6
|
+
// defineTool / registerTool -> @deepseek-ai/dsh-tools 的 defineTool + ctx.tools.register
|
|
7
|
+
// handle -> 收进 handlers 表,供宿主 HTTP 路由转发(见 rpcRoute)
|
|
8
|
+
//
|
|
9
|
+
// 这样做的理由:机械改写上千行主体逻辑的风险远高于加一层适配,
|
|
10
|
+
// 而且适配层把「动态 ↔ 静态」的差异集中在一个地方,便于日后核对。
|
|
11
|
+
//
|
|
12
|
+
// ── defineTool 的入参形态差异(实测踩坑,务必保留转换)────────────────────────
|
|
13
|
+
// 动态半边的 harness.defineTool 由 dsh-cordis-host-runner 的 guard 提供,它按
|
|
14
|
+
// 「JSON Schema」接受 parameters({ type:'object', properties, required })。
|
|
15
|
+
// 静态包的 defineTool 来自 @deepseek-ai/dsh-tools,它要的是 ParameterSchemaSpec:
|
|
16
|
+
// 一个**扁平的属性表**,必填写成每个属性上的 required: true,且根对象没有 type 字段。
|
|
17
|
+
// 直接把 JSON Schema 喂给静态 defineTool 会抛
|
|
18
|
+
// JsonSchemaError: unsupported JSON schema: parameters.type must be a value schema object
|
|
19
|
+
// —— 工具会在 apply 时全部注册失败。
|
|
20
|
+
// 所以这里做一次转换,src/ 保持动态形态不变。
|
|
21
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
|
+
import { rptInstallWorkspaceReports } from '../src/workspace-install.js'
|
|
23
|
+
|
|
24
|
+
// JSON Schema 属性节点 -> ParameterSchemaSpec 属性节点。只带上工具真的用到的键,
|
|
25
|
+
// 不搬运 pattern / format 之类静态编译器不接受的约束。
|
|
26
|
+
function toPropertySpec(node) {
|
|
27
|
+
if (!node || typeof node !== 'object') return { type: 'string' }
|
|
28
|
+
const annotations = {}
|
|
29
|
+
if (typeof node.description === 'string') annotations.description = node.description
|
|
30
|
+
if (node.default !== undefined) annotations.default = node.default
|
|
31
|
+
if (Array.isArray(node.examples)) annotations.examples = node.examples
|
|
32
|
+
const t = node.type
|
|
33
|
+
if (t === 'array') {
|
|
34
|
+
const spec = { type: 'array', items: toPropertySpec(node.items), ...annotations }
|
|
35
|
+
if (typeof node.minItems === 'number') spec.minItems = node.minItems
|
|
36
|
+
if (typeof node.maxItems === 'number') spec.maxItems = node.maxItems
|
|
37
|
+
return spec
|
|
38
|
+
}
|
|
39
|
+
if (t === 'object') {
|
|
40
|
+
return { type: 'object', additionalProperties: node.additionalProperties === false ? false : true, properties: toPropertyMap(node.properties), ...annotations }
|
|
41
|
+
}
|
|
42
|
+
const spec = { type: t || 'string', ...annotations }
|
|
43
|
+
if (Array.isArray(node.enum)) spec.enum = node.enum.slice()
|
|
44
|
+
if (node.const !== undefined) spec.const = node.const
|
|
45
|
+
return spec
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toPropertyMap(props) {
|
|
49
|
+
const out = {}
|
|
50
|
+
if (props && typeof props === 'object') for (const key of Object.keys(props)) out[key] = toPropertySpec(props[key])
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 接受动态形态的 parameters;已是扁平属性表时原样返回(幂等,便于两种写法共存)。
|
|
55
|
+
function toParameterSpec(parameters, required) {
|
|
56
|
+
if (!parameters || typeof parameters !== 'object') return { type: 'object', properties: {}, additionalProperties: false }
|
|
57
|
+
let props = parameters.properties
|
|
58
|
+
if (props === undefined && parameters.type !== 'object') props = parameters
|
|
59
|
+
const map = toPropertyMap(props)
|
|
60
|
+
const req = Array.isArray(required) ? required : (Array.isArray(parameters.required) ? parameters.required : [])
|
|
61
|
+
for (const name of req) if (map[name] && typeof map[name] === 'object') map[name].required = true
|
|
62
|
+
return map
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 工具定义里除了 parameters 之外都与静态 defineTool 兼容,只替换这一个字段。
|
|
66
|
+
function toStaticToolDefinition(definition) {
|
|
67
|
+
const rest = {}
|
|
68
|
+
for (const key of Object.keys(definition)) if (key !== 'parameters') rest[key] = definition[key]
|
|
69
|
+
rest.parameters = toParameterSpec(definition.parameters, definition.required)
|
|
70
|
+
return rest
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function applyHost(ctx) {
|
|
74
|
+
const handlers = Object.create(null)
|
|
75
|
+
const harness = {
|
|
76
|
+
defineTool(definition) { return defineTool(toStaticToolDefinition(definition)) },
|
|
77
|
+
registerTool(c, tool) { return c.tools.register(tool) },
|
|
78
|
+
handle(method, handler) { handlers[method] = handler; return () => { delete handlers[method] } },
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
await rptInstallWorkspaceReports(ctx, harness, { hostSource: "// 红队报告 · Host 半边主体\n//\n// 本文件是 applyHost 的【函数体】——函数头、harness 垫片、收尾与导出都由\n// lib/parts/host.head.js 与 host.tail.js 提供,所以这里不要写 import、function 头或 return 块。\n// lib/host.js 由 `npm run build:lib` 生成,不要手改 lib/。\n//\n// ── 这个插件干什么 ────────────────────────────────────────────────────────────\n// 把一次测试的**证据**汇成一份能交付的报告:\n// 1. 工作区对话(用户要求 / 关键操作 / 结果与结论)\n// 2. 攻击矩阵的命中(已确认与疑似分开,带技术点名字、判据、打过的目标、证据片段)\n// 3. 记忆库里与本次测试相关的知识条目\n// 然后调**当前会话正在用的那个模型**(llm 服务)自动撰写,人在面板上改与预览,\n// 最后导出成 Markdown / HTML / Word(.docx),或者反手导入记忆库。\n//\n// ── 三个关键设计 ──────────────────────────────────────────────────────────────\n// 1. **不自己攒模型凭据**:报告由 `llm.stream({provider, model})` 生成,provider/model\n// 默认取 agentDefaultModel.currentSelection(),也就是你正在对话的那个模型。\n// 于是「AI 自动撰写」这件事不需要用户再配一个 Key。\n// 2. **证据与撰写分离**:collectEvidence() 只管把事实收齐、按预算裁剪成 digest;\n// 撰写只是一次带 digest 的补全。想换提示词不用动采集;想看 AI 到底看到了什么,\n// 面板上的「试算证据」把 digest 直接打出来。\n// 3. **生成是后台任务**:报告要写几千字,RPC 不能一直挂着。generate 立刻返回,\n// 正文边流边写进报告对象,面板轮询 snapshot 就能看到进度与半成品。\n//\n// 证据来源的两种情形(都要能跑):\n// - 攻击矩阵:优先用 `redteamAttackMatrix` 服务(有技术点名字);没有服务就**直接读**\n// 工作区里的 .redteam-attack-matrix.json(读任何路径都允许,只是拿不到名字)。\n// - 记忆库:用 `redteamMemory` 服务检索;没在跑就跳过,并在报告来源里说明。\n//\n// 动态半边写文件的沙箱边界(实测,见 ../../docs/DEVELOPMENT.md §5.1):\n// 相对路径落在插件自己的工作区;写它之外的绝对路径会被拒。导出路径因此默认用\n// 相对名并把解析出的宿主路径回显给用户,写不进去时面板上能看见原因。\n//\n// markdown → 块 / HTML / docx 的纯函数在 src/docx.js,由生成器内联到本文件末尾的\n// 占位注释处(见 tools/build-lib.mjs 的 DOCX_MARKER;运行期不需要额外文件)。\n\n // ── 常量 ──────────────────────────────────────────────────────────────────\n const STORE_NAME = '.redteam-report.json'\n const STORE_VERSION = 1\n const LOG_MAX = 120\n const SHELL_TIMEOUT_MS = 20000\n const DEFAULT_MATRIX_STORE = '.redteam-attack-matrix.json'\n const SESSION_MAX_USERS = 6\n const SESSION_MAX_OPS = 24\n const SESSION_MAX_RESULTS = 12\n const OUTLINE = [\n '## 1. 概述(测试目标、时间范围、授权与范围假设)',\n '## 2. 测试方法与过程(按阶段写:侦察 / 进入 / 利用 / 影响验证)',\n '## 3. 已确认的发现(每条写:现象 → 证据 → 影响 → 复现步骤 → 修复建议)',\n '## 4. 疑似与待验证(说明为什么没确认、下一步怎么验证)',\n '## 5. 攻击面覆盖(对照攻击矩阵,说明已覆盖与明显缺口)',\n '## 6. 风险评级与优先级(高/中/低,给理由)',\n '## 7. 清理与合规(清掉了什么、留下了什么、哪些动作有副作用)',\n ]\n\n function blankSettings() {\n return {\n // 留空 = 用当前会话的默认模型(agentDefaultModel)。\n model: { provider: '', model: '' },\n instruction: '',\n sessionLimit: 8,\n sessionChars: 5000,\n maxConfirmed: 40,\n maxSuspected: 25,\n memoryTopK: 5,\n memoryQueries: 6,\n digestMax: 48000,\n matrixStore: '',\n exportDir: '',\n maxTokens: 8000,\n }\n }\n\n function blankStore() {\n return {\n version: STORE_VERSION,\n updatedAt: 0,\n settings: blankSettings(),\n reports: [],\n currentId: '',\n log: [],\n logSeq: 0,\n meta: {\n persistence: 'unknown', storePath: '', lastError: null, progress: null,\n lastOp: null, generating: false, evidence: null,\n },\n }\n }\n\n // ── 工具函数 ──────────────────────────────────────────────────────────────\n function msgOf(e) { return e && e.message ? String(e.message) : String(e) }\n function nowMs() { return Date.now() }\n function clip(s, n) {\n const v = String(s === undefined || s === null ? '' : s).replace(/\\s+/g, ' ').trim()\n return v.length > n ? v.slice(0, n - 1) + '…' : v\n }\n function intOf(v, d) { const n = Number(v); return Number.isFinite(n) ? Math.round(n) : d }\n function shQuote(s) { return \"'\" + String(s === undefined || s === null ? '' : s).replace(/'/g, \"'\\\\''\") + \"'\" }\n function stamp() { return new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') }\n function fmtTime(ms) {\n try {\n const d = new Date(Number(ms) || 0)\n const p = function (n) { return String(n).padStart(2, '0') }\n return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes())\n } catch (e) { return '' }\n }\n function newId() { return 'r' + nowMs().toString(36) + '-' + String(Math.floor(Math.random() * 1e6)).toString(36) }\n\n function log(level, text) {\n store.logSeq = (store.logSeq || 0) + 1\n store.log.push({ seq: store.logSeq, at: nowMs(), level: level, text: String(text).slice(0, 1200) })\n if (store.log.length > LOG_MAX) store.log = store.log.slice(store.log.length - LOG_MAX)\n }\n\n // 内容块 -> 纯文本。tool-result 的 content 是嵌套的,要递归下去,\n // 否则工具输出(报告里最硬的那部分证据)会全丢。\n function plainText(content) {\n if (typeof content === 'string') return content\n if (!Array.isArray(content)) return ''\n const out = []\n for (const b of content) {\n if (!b || typeof b !== 'object') continue\n if (b.type === 'text' && typeof b.text === 'string') out.push(b.text)\n else if (b.type === 'tool-result') out.push(plainText(b.content))\n }\n return out.join('\\n')\n }\n\n // ── 落盘 ──────────────────────────────────────────────────────────────────\n const store = blankStore()\n let loaded = false\n\n function settings() { return store.settings }\n\n async function resolveTarget() {\n const fs = ctx.get('fs')\n if (fs === undefined || fs === null) return null\n const target = await fs.resolve(store.settings.storePath || STORE_NAME)\n return { fs: fs, target: target }\n }\n\n function hostPathOf(fs, target) {\n try {\n if (typeof fs.processPath === 'function') return String(fs.processPath(target) || '')\n } catch (e) { /* 沙箱实现没有 processPath 时留空 */ }\n return ''\n }\n\n function mergeSettings(src) {\n const d = blankSettings()\n const s = store.settings\n const g = src.model\n if (g && typeof g === 'object') {\n for (const k of Object.keys(d.model)) if (g[k] !== undefined && g[k] !== null) s.model[k] = String(g[k])\n }\n if (src.instruction !== undefined) s.instruction = String(src.instruction || '').slice(0, 4000)\n if (src.matrixStore !== undefined) s.matrixStore = String(src.matrixStore || '').slice(0, 400)\n if (src.exportDir !== undefined) s.exportDir = String(src.exportDir || '').slice(0, 400)\n s.sessionLimit = Math.max(1, Math.min(60, intOf(src.sessionLimit, s.sessionLimit)))\n s.sessionChars = Math.max(500, Math.min(40000, intOf(src.sessionChars, s.sessionChars)))\n s.maxConfirmed = Math.max(1, Math.min(200, intOf(src.maxConfirmed, s.maxConfirmed)))\n s.maxSuspected = Math.max(0, Math.min(200, intOf(src.maxSuspected, s.maxSuspected)))\n s.memoryTopK = Math.max(1, Math.min(20, intOf(src.memoryTopK, s.memoryTopK)))\n s.memoryQueries = Math.max(0, Math.min(20, intOf(src.memoryQueries, s.memoryQueries)))\n s.digestMax = Math.max(4000, Math.min(200000, intOf(src.digestMax, s.digestMax)))\n s.maxTokens = Math.max(500, Math.min(64000, intOf(src.maxTokens, s.maxTokens)))\n }\n\n async function doLoad() {\n try {\n const r = await resolveTarget()\n if (!r) { store.meta.persistence = 'memory'; log('warn', 'fs 服务不可用,报告只在内存里,重启会丢'); return 0 }\n store.meta.storePath = hostPathOf(r.fs, r.target)\n const info = await r.fs.stat(r.target)\n const parsed = info ? JSON.parse(await r.fs.readText(r.target)) : null\n if (parsed && typeof parsed === 'object') {\n if (Number(parsed.version) !== STORE_VERSION) log('warn', '报告存储版本 ' + parsed.version + ' -> ' + STORE_VERSION + ',按字段合并')\n if (parsed.settings && typeof parsed.settings === 'object') mergeSettings(parsed.settings)\n if (Array.isArray(parsed.reports)) {\n for (const x of parsed.reports) {\n if (!x || typeof x !== 'object') continue\n store.reports.push({\n id: String(x.id || newId()),\n title: clip(x.title || '未命名报告', 200),\n markdown: String(x.markdown || ''),\n createdAt: intOf(x.createdAt, 0) || nowMs(),\n updatedAt: intOf(x.updatedAt, 0) || nowMs(),\n meta: x.meta && typeof x.meta === 'object' ? x.meta : {},\n })\n }\n }\n if (typeof parsed.currentId === 'string') store.currentId = parsed.currentId\n if (Array.isArray(parsed.log)) store.log = parsed.log.slice(-LOG_MAX)\n if (typeof parsed.logSeq === 'number') store.logSeq = parsed.logSeq\n if (parsed.meta && typeof parsed.meta === 'object' && parsed.meta.evidence) store.meta.evidence = parsed.meta.evidence\n }\n if (!currentReport() && store.reports.length) store.currentId = store.reports[store.reports.length - 1].id\n store.meta.persistence = 'ready'\n return store.reports.length\n } catch (e) {\n const m = msgOf(e)\n if (!/ENOENT|not found|不存在|null/i.test(m)) {\n store.meta.persistence = 'error'\n store.meta.lastError = '读取报告库失败:' + m\n log('err', store.meta.lastError)\n } else {\n store.meta.persistence = 'ready'\n }\n return 0\n }\n }\n\n async function persist() {\n store.updatedAt = nowMs()\n const r = await resolveTarget()\n if (!r) return false\n try {\n const payload = {\n version: STORE_VERSION,\n updatedAt: store.updatedAt,\n settings: store.settings,\n reports: store.reports,\n currentId: store.currentId,\n log: store.log.slice(-LOG_MAX),\n logSeq: store.logSeq,\n meta: { evidence: store.meta.evidence || null },\n }\n await r.fs.writeText(r.target, JSON.stringify(payload, null, 2))\n store.meta.storePath = hostPathOf(r.fs, r.target)\n store.meta.persistence = 'ready'\n return true\n } catch (e) {\n store.meta.persistence = 'error'\n store.meta.lastError = '写入失败:' + msgOf(e)\n log('err', store.meta.lastError)\n return false\n }\n }\n\n async function ensureLoaded() { if (loaded) return 0; loaded = true; return await doLoad() }\n\n function currentReport() {\n for (const r of store.reports) if (r.id === store.currentId) return r\n return null\n }\n\n function newReport(title) {\n const at = nowMs()\n return { id: newId(), title: clip(title || '未命名报告', 200), markdown: '', createdAt: at, updatedAt: at, meta: {} }\n }\n\n // ── 工作区与会话 ──────────────────────────────────────────────────────────\n function workspaceRegistry() {\n const reg = ctx.get('workspaceRegistry')\n if (!reg || typeof reg.list !== 'function') return null\n return reg\n }\n\n function listWorkspaces() {\n const reg = workspaceRegistry()\n if (!reg) return []\n let raw = null\n try { raw = reg.list() } catch (e) { return [] }\n if (!Array.isArray(raw)) return []\n const out = []\n for (const w of raw) {\n if (!w) continue\n const ids = Array.isArray(w.sessionIds) ? w.sessionIds : []\n out.push({ id: String(w.id || ''), path: String(w.path || ''), title: String(w.title || ''), sessionCount: ids.length })\n }\n return out\n }\n\n // 当前会话所在的工作区;取不到就退到第一个工作区。\n function currentWorkspace() {\n const reg = workspaceRegistry()\n if (!reg) return null\n try {\n const agents = ctx.get('agents')\n let agent = null\n if (agents && typeof agents.currentInitiator === 'function') agent = agents.currentInitiator()\n if (!agent && agents && typeof agents.roots === 'function') {\n const roots = agents.roots()\n if (Array.isArray(roots) && roots.length) agent = roots[0]\n }\n if (agent) {\n const sid = String(agent.id)\n for (const w of listWorkspaces()) {\n let full = null\n try { full = typeof reg.get === 'function' ? reg.get(w.id) : null } catch (e) { full = null }\n const ids = full && Array.isArray(full.sessionIds) ? full.sessionIds : []\n for (const x of ids) if (String(x) === sid) return w\n }\n }\n } catch (e) { /* 退到第一个工作区 */ }\n const all = listWorkspaces()\n return all.length > 0 ? all[0] : null\n }\n\n function sessionsOf(workspaceId) {\n const reg = workspaceRegistry()\n const sessions = ctx.get('sessions')\n if (!reg || !sessions || typeof sessions.get !== 'function') return []\n let w = null\n try { w = typeof reg.get === 'function' ? reg.get(workspaceId) : null } catch (e) { return [] }\n if (!w || !Array.isArray(w.sessionIds)) return []\n const out = []\n for (const id of w.sessionIds) {\n let s = null\n try { s = sessions.get(id) } catch (e) { continue }\n if (s) out.push(s)\n }\n return out\n }\n\n function sessionTitleOf(session) {\n const svc = ctx.get('sessionTitle')\n try {\n if (svc && typeof svc.get === 'function') {\n const t = svc.get(session)\n const v = t && (t.title || t.value || t.text)\n if (v) return clip(v, 80)\n }\n } catch (e) { /* 标题只为人看着方便 */ }\n return ''\n }\n\n // 一个会话压成三块:用户要求 / 关键操作 / 结果与结论。\n // 这是「报告能引用的最小可读单位」,也是 digest 预算的分配单位。\n function sessionDigest(session, chars) {\n let events = []\n try { events = session.snapshotEvents() } catch (e) { return null }\n if (!Array.isArray(events) || !events.length) return null\n const users = [], ops = [], results = []\n let firstAt = 0, lastAt = 0\n for (const ev of events) {\n if (!ev || !ev.data) continue\n const at = Number(ev.time) || 0\n if (at) { if (!firstAt || at < firstAt) firstAt = at; if (at > lastAt) lastAt = at }\n if (ev.type === 'user/message') {\n const t = plainText(ev.data.content)\n if (t.trim() && users.length < SESSION_MAX_USERS) users.push(clip(t, 400))\n } else if (ev.type === 'tool/call') {\n if (ops.length < SESSION_MAX_OPS) ops.push(clip(String(ev.data.name || '') + ' ' + String(ev.data.arguments || ''), 200))\n } else if (ev.type === 'tool/result') {\n const t = plainText(ev.data.message && ev.data.message.content)\n if (t.trim() && results.length < SESSION_MAX_RESULTS) results.push(clip(t, 300))\n } else if (ev.type === 'assistant/message') {\n const t = plainText(ev.data.message && ev.data.message.content)\n if (t.trim() && results.length < SESSION_MAX_RESULTS) results.push('(模型结论)' + clip(t, 240))\n }\n }\n const L = []\n L.push('### 会话:' + (sessionTitleOf(session) || String(session.id).slice(0, 16)))\n if (firstAt) L.push('时间:' + fmtTime(firstAt) + ' → ' + fmtTime(lastAt))\n if (users.length) { L.push('用户要求:'); for (const u of users) L.push('- ' + u) }\n if (ops.length) { L.push('关键操作(工具调用):'); for (const o of ops) L.push('- ' + o) }\n if (results.length) { L.push('结果与结论:'); for (const r of results) L.push('- ' + r) }\n const text = L.join('\\n')\n return {\n id: String(session.id), title: sessionTitleOf(session), firstAt: firstAt, lastAt: lastAt,\n users: users, ops: ops.length, results: results.length,\n text: text.length > chars ? text.slice(0, chars) + '\\n(本会话已截断)' : text,\n }\n }\n\n // ── 证据:攻击矩阵 ────────────────────────────────────────────────────────\n function matrixPath() {\n const p = String(settings().matrixStore || '').trim()\n if (p) return p\n const w = currentWorkspace()\n return w && w.path ? String(w.path).replace(/\\/+$/, '') + '/' + DEFAULT_MATRIX_STORE : DEFAULT_MATRIX_STORE\n }\n\n // 没有攻击矩阵插件在跑时的退路:直接读它的存储文件(读任何路径都允许)。\n // 代价是拿不到技术点名字 —— 所以只当退路,不当主路。\n async function matrixFromFile(path) {\n const fs = ctx.get('fs')\n if (!fs || typeof fs.resolve !== 'function') return null\n const target = await fs.resolve(path)\n const info = await fs.stat(target)\n if (!info) return { from: 'file', items: [], confirmed: 0, suspected: 0, storePath: path, missing: true }\n const parsed = JSON.parse(await fs.readText(target))\n const items = []\n const matrix = parsed && parsed.matrix && typeof parsed.matrix === 'object' ? parsed.matrix : {}\n for (const fwId of Object.keys(matrix)) {\n const techs = matrix[fwId] || {}\n for (const tid of Object.keys(techs)) {\n const hits = techs[tid] || {}\n for (const sid of Object.keys(hits)) {\n const h = hits[sid]\n if (!h || h.confidence === 'rejected') continue\n items.push({\n frameworkId: fwId, frameworkLabel: fwId, techniqueId: tid, techniqueName: '',\n sessionId: sid, sessionTitle: String(h.sessionTitle || ''),\n confidence: h.confidence === 'confirmed' ? 'confirmed' : 'suspected',\n decidedBy: String(h.decidedBy || ''), reason: String(h.reason || ''),\n occurrences: intOf(h.occurrences, 0), firstAt: intOf(h.firstAt, 0), lastAt: intOf(h.lastAt, 0),\n kind: String(h.kind || ''), matched: (h.matched || []).slice(0, 10), targets: (h.targets || []).slice(0, 10),\n snippets: (h.snippets || []).slice(-3).map(function (x) { return { at: x.at, label: x.label, text: clip(x.text, 400) } }),\n })\n }\n }\n }\n const out = { from: 'file', items: items, storePath: path, updatedAt: intOf(parsed.updatedAt, 0) }\n out.confirmed = items.filter(function (x) { return x.confidence === 'confirmed' }).length\n out.suspected = items.length - out.confirmed\n return out\n }\n\n async function collectMatrix() {\n const svc = ctx.get('redteamAttackMatrix')\n if (svc && typeof svc.digest === 'function') {\n try {\n const d = await svc.digest()\n if (d && Array.isArray(d.items)) return Object.assign({ from: 'service' }, d)\n } catch (e) {\n log('warn', '从攻击矩阵服务取数失败,改读存储文件:' + msgOf(e))\n }\n }\n const path = matrixPath()\n try {\n const d = await matrixFromFile(path)\n if (!d) return { from: 'none', items: [], confirmed: 0, suspected: 0, storePath: path }\n // 服务在跑但 digest 失败时,至少把技术点名字补上。\n if (svc && typeof svc.names === 'function') {\n try {\n const names = svc.names()\n for (const it of d.items) {\n const fw = names[it.frameworkId]\n if (!fw) continue\n it.frameworkLabel = fw.label || it.frameworkId\n it.frameworkShort = fw.short || it.frameworkLabel\n if (fw.techniques && fw.techniques[it.techniqueId]) it.techniqueName = fw.techniques[it.techniqueId]\n }\n } catch (e) { /* 名字是加分项,拿不到就算了 */ }\n }\n d.items.sort(function (a, b) {\n return (b.confidence === 'confirmed' ? 1 : 0) - (a.confidence === 'confirmed' ? 1 : 0) || (b.lastAt - a.lastAt)\n })\n return d\n } catch (e) {\n return { from: 'none', items: [], confirmed: 0, suspected: 0, storePath: path, error: msgOf(e) }\n }\n }\n\n // ── 证据:记忆库 ──────────────────────────────────────────────────────────\n async function collectMemory(queries) {\n const svc = ctx.get('redteamMemory')\n if (!svc || typeof svc.search !== 'function') {\n return { available: false, hits: [], note: '记忆插件没在运行(redteamMemory 服务不可用),本次报告不含记忆条目' }\n }\n const seen = {}\n const hits = []\n const budget = Math.max(0, intOf(settings().memoryQueries, 6))\n const used = queries.slice(0, budget)\n for (const q of used) {\n if (!q || !String(q).trim()) continue\n try {\n const r = await svc.search(String(q), settings().memoryTopK, { rerank: false })\n for (const h of (r && r.hits) || []) {\n const id = String(h.id || h.entryId || h.title)\n if (seen[id]) continue\n seen[id] = true\n hits.push({\n id: id, title: String(h.title || ''), kind: String(h.kind || ''), tags: String(h.tags || ''),\n text: clip(h.text, 500), source: String(h.source || ''), query: String(q), mode: String(h.mode || ''),\n })\n }\n } catch (e) {\n log('warn', '记忆检索失败(' + clip(q, 40) + '):' + msgOf(e))\n }\n }\n return { available: true, hits: hits, queries: used }\n }\n\n // ── 证据采集与 digest ─────────────────────────────────────────────────────\n async function collectEvidence() {\n const s = settings()\n const w = currentWorkspace()\n const sessions = []\n if (w) {\n // 最近的会话优先:报告写的是这次测试,不是三个月前的。\n const withTime = []\n for (const session of sessionsOf(w.id)) {\n let last = 0\n try {\n const evs = session.snapshotEvents()\n if (Array.isArray(evs) && evs.length) last = Number(evs[evs.length - 1].time) || 0\n } catch (e) { /* 拿不到时间就排最后 */ }\n withTime.push({ session: session, last: last })\n }\n withTime.sort(function (a, b) { return b.last - a.last })\n for (const x of withTime.slice(0, s.sessionLimit)) {\n const d = sessionDigest(x.session, s.sessionChars)\n if (d) sessions.push(d)\n }\n }\n\n const matrix = await collectMatrix()\n const confirmed = matrix.items.filter(function (x) { return x.confidence === 'confirmed' }).slice(0, s.maxConfirmed)\n const suspected = matrix.items.filter(function (x) { return x.confidence !== 'confirmed' }).slice(0, s.maxSuspected)\n\n // 记忆检索的 query:先拿矩阵里确认的技术点名字(那是本次测试「发生了什么」),\n // 再补每个会话的第一条用户要求(那是「想做什么」)。\n const queries = []\n for (const it of confirmed.concat(suspected)) {\n const name = it.techniqueName || it.techniqueId\n if (name && queries.indexOf(name) < 0) queries.push(name)\n }\n for (const d of sessions) for (const u of (d.users || []).slice(0, 1)) if (queries.indexOf(u) < 0) queries.push(clip(u, 60))\n const memory = await collectMemory(queries)\n\n return {\n at: nowMs(),\n workspace: w ? { id: w.id, title: w.title, path: w.path } : null,\n sessions: sessions,\n matrix: {\n from: matrix.from, storePath: matrix.storePath || '', error: matrix.error || null,\n updatedAt: matrix.updatedAt || 0, total: matrix.items.length,\n confirmed: confirmed, suspected: suspected,\n confirmedAll: intOf(matrix.confirmed, confirmed.length), suspectedAll: intOf(matrix.suspected, suspected.length),\n },\n memory: memory,\n queries: queries,\n }\n }\n\n function matrixLines(items, kind) {\n const out = []\n for (const it of items) {\n const fw = it.frameworkShort || it.frameworkLabel || it.frameworkId\n const name = it.techniqueName ? '(' + it.techniqueName + ')' : ''\n const bits = []\n if (it.occurrences) bits.push('出现 ' + it.occurrences + ' 次')\n if (it.firstAt) bits.push(fmtTime(it.firstAt) + (it.lastAt && it.lastAt !== it.firstAt ? ' → ' + fmtTime(it.lastAt) : ''))\n if (it.sessionTitle) bits.push('会话「' + clip(it.sessionTitle, 40) + '」')\n if (it.decidedBy) bits.push('判定方 ' + it.decidedBy)\n out.push('- ' + fw + ' / ' + it.techniqueId + name + (bits.length ? '|' + bits.join('|') : ''))\n if (it.reason) out.push(' 判据:' + clip(it.reason, 300))\n if (it.targets && it.targets.length) out.push(' 目标:' + clip(it.targets.join('、'), 200))\n if (it.matched && it.matched.length) out.push(' 命中线索:' + clip(it.matched.join('、'), 160))\n // 疑似条目的片段不进 digest:那些多半是关键词撞上的原文,放进去只会把报告带偏。\n if (kind === 'confirmed') {\n for (const sn of (it.snippets || []).slice(-2)) out.push(' 证据片段[' + (sn.label || '') + ' ' + fmtTime(sn.at) + ']:' + clip(sn.text, 300))\n }\n }\n return out\n }\n\n function buildDigest(ev) {\n const s = settings()\n const L = []\n L.push('# 证据材料(自动采集;报告只能引用这里出现过的事实)')\n L.push('')\n L.push('## 一、工作区与会话')\n L.push('- 工作区:' + (ev.workspace ? (ev.workspace.title || '') + '(' + ev.workspace.path + ')' : '(拿不到工作区)'))\n L.push('- 采集时间:' + fmtTime(ev.at))\n L.push('- 会话数:' + ev.sessions.length)\n for (const d of ev.sessions) { L.push(''); L.push(d.text) }\n\n L.push('')\n L.push('## 二、攻击矩阵命中')\n L.push('- 来源:' + (ev.matrix.from === 'service' ? '攻击矩阵插件(含技术点名字)'\n : ev.matrix.from === 'file' ? '直接读 ' + ev.matrix.storePath + '(拿不到技术点名字,只有 id)' : '没有可用数据'))\n if (ev.matrix.error) L.push('- 读取问题:' + ev.matrix.error)\n L.push('- 已确认 ' + ev.matrix.confirmed.length + ' 条(全部 ' + ev.matrix.confirmedAll + ' 条)· 疑似 '\n + ev.matrix.suspected.length + ' 条(全部 ' + ev.matrix.suspectedAll + ' 条)')\n L.push('')\n L.push('### 已确认(有做成的证据)')\n const cf = matrixLines(ev.matrix.confirmed, 'confirmed')\n if (cf.length) for (const x of cf) L.push(x); else L.push('- (无)')\n L.push('')\n L.push('### 疑似(提及或尝试过,但没证明成功)')\n const sp = matrixLines(ev.matrix.suspected, 'suspected')\n if (sp.length) for (const x of sp) L.push(x); else L.push('- (无)')\n\n L.push('')\n L.push('## 三、记忆库里与本次测试相关的知识')\n if (!ev.memory.available) L.push('- ' + ev.memory.note)\n else if (!ev.memory.hits.length) L.push('- (没有检索到相关条目)')\n else for (const h of ev.memory.hits) L.push('- [' + (h.kind || 'knowledge') + '] ' + h.title + ':' + clip(h.text, 400))\n\n let text = L.join('\\n')\n if (text.length > s.digestMax) {\n text = text.slice(0, s.digestMax) + '\\n\\n(证据材料超过 ' + s.digestMax + ' 字,已截断;可在设置里调大上限或减少会话数)'\n }\n return text\n }\n\n function buildSystemPrompt() {\n return [\n '你是红队报告撰写助手。你会拿到一份自动采集的证据材料,然后写一份中文技术报告(Markdown)。',\n '',\n '纪律(这些比文采重要):',\n '1. 只能写证据材料里出现过的事实。不要编造 IP、端口、URL、命令回显、时间、数量。',\n '2. 攻击矩阵的「已确认」才能写成已确认的发现;「疑似」必须放在疑似与待验证一节,并写清为什么没确认。',\n '3. 每条发现都要能指回证据:写清出现在哪个会话、哪个技术点、什么目标。',\n '4. 数字(出现次数、时间区间)必须与证据材料一致,不要四舍五入成好看的数字。',\n '5. 证据不足就写「证据不足」——一份诚实的中等报告比一份编造的优秀报告有用得多。',\n '6. 不要用代码围栏包住整篇报告,直接给 Markdown 正文。',\n ].join('\\n')\n }\n\n function buildUserPrompt(digest, instruction) {\n const L = []\n L.push('请根据下面的证据材料撰写红队测试报告。')\n L.push('')\n L.push('按这个大纲组织(标题层级用 ##):')\n for (const x of OUTLINE) L.push(x)\n L.push('')\n L.push('开头用一行 `# 标题` 给出报告标题(包含测试对象与时间范围,不要只写「红队报告」)。')\n if (String(instruction || '').trim()) {\n L.push('')\n L.push('额外要求(必须满足):')\n L.push(String(instruction).trim())\n }\n L.push('')\n L.push('---')\n L.push('')\n L.push(digest)\n return L.join('\\n')\n }\n\n // ── 生成(后台任务,边流边写)────────────────────────────────────────────\n const gen = { active: false, reportId: '', chars: 0, startedAt: 0 }\n\n function pickModel() {\n const s = settings()\n const llm = ctx.get('llm')\n const out = { provider: String(s.model.provider || '').trim(), model: String(s.model.model || '').trim(), from: 'settings' }\n if (!out.provider || !out.model) {\n const svc = ctx.get('agentDefaultModel')\n let sel = null\n try { if (svc && typeof svc.currentSelection === 'function') sel = svc.currentSelection() } catch (e) { sel = null }\n if (sel && sel.provider && sel.model) {\n out.provider = String(sel.provider)\n out.model = String(sel.model)\n out.from = 'default'\n }\n }\n if (!out.provider) {\n // 最后退到 llm 注册的第一个 provider(列不出模型时至少给个能用的路由)\n try {\n const provs = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : null\n if (Array.isArray(provs) && provs.length && provs[0] && provs[0].id) {\n out.provider = String(provs[0].id)\n out.from = 'provider'\n }\n } catch (e) { /* 下面统一报错 */ }\n }\n return out\n }\n\n async function runGenerate(report, instruction) {\n const llm = ctx.get('llm')\n if (!llm || typeof llm.stream !== 'function') throw new Error('llm 服务不可用,没法自动撰写(宿主里要有 llm 插件)')\n const sel = pickModel()\n if (!sel.provider || !sel.model) throw new Error('拿不到可用的模型:设置里没填,当前会话也没有默认模型')\n\n const ev = await collectEvidence()\n const digest = buildDigest(ev)\n const messages = [{ id: 'rpt-' + nowMs(), role: 'user', content: [{ type: 'text', text: buildUserPrompt(digest, instruction) }], source: { kind: 'user' } }]\n\n log('info', '开始撰写报告(' + sel.provider + '/' + sel.model + ',证据 ' + digest.length + ' 字:' + ev.sessions.length + ' 个会话,已确认 '\n + ev.matrix.confirmed.length + ' 条,疑似 ' + ev.matrix.suspected.length + ' 条,记忆 ' + ev.memory.hits.length + ' 条)')\n\n const parts = []\n let usage = null\n let finish = null\n let lastFlush = 0\n const stream = llm.stream({\n provider: sel.provider, model: sel.model, system: buildSystemPrompt(),\n messages: messages, maxTokens: settings().maxTokens,\n })\n\n for await (const chunk of stream) {\n if (!chunk || typeof chunk !== 'object') continue\n if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {\n parts.push(chunk.text)\n // 边流边写回报告对象:面板轮询 snapshot 就能看到正在长出来的正文。\n gen.chars = parts.join('').length\n store.meta.progress = { text: '正在撰写… ' + gen.chars + ' 字', chars: gen.chars, total: 0 }\n const now = nowMs()\n if (now - lastFlush > 800) {\n lastFlush = now\n report.markdown = parts.join('')\n report.updatedAt = now\n }\n } else if (chunk.type === 'usage' && chunk.usage) {\n usage = chunk.usage\n } else if (chunk.type === 'finish') {\n finish = chunk.reason\n }\n }\n\n const text = parts.join('')\n report.markdown = text\n report.updatedAt = nowMs()\n if (!report.title || report.title === '未命名报告') {\n const m = /^\\s*#\\s+(.+)$/m.exec(text)\n report.title = clip(m ? m[1] : ('红队测试报告 ' + fmtTime(report.createdAt)), 200)\n }\n report.meta = Object.assign({}, report.meta, {\n provider: sel.provider, model: sel.model, modelFrom: sel.from,\n chars: text.length, usage: usage || null, generatedAt: report.updatedAt,\n evidence: {\n sessions: ev.sessions.length,\n matrixConfirmed: ev.matrix.confirmed.length,\n matrixSuspected: ev.matrix.suspected.length,\n matrixFrom: ev.matrix.from,\n memoryHits: ev.memory.hits.length,\n memoryAvailable: ev.memory.available,\n digestChars: digest.length,\n },\n instruction: String(instruction || '').slice(0, 2000),\n })\n store.meta.evidence = report.meta.evidence\n\n if (finish && finish.kind === 'error') throw new Error('模型返回错误:' + ((finish.failure && finish.failure.message) || '未知'))\n if (finish && finish.kind === 'aborted') throw new Error('生成被中止:' + ((finish.failure && finish.failure.message) || ''))\n if (!text.trim()) throw new Error('模型没有返回任何正文(finish = ' + (finish ? finish.kind : '?') + ')')\n return { chars: text.length, evidence: report.meta.evidence }\n }\n\n // 生成失败且一个字都没写出来时,把这份空报告撤掉:面板里留一堆空壳只会让人困惑。\n function dropIfEmpty(report) {\n if (String(report.markdown || '').trim()) return false\n store.reports = store.reports.filter(function (r) { return r.id !== report.id })\n if (store.currentId === report.id) store.currentId = store.reports.length ? store.reports[store.reports.length - 1].id : ''\n return true\n }\n\n function startGenerate(report, instruction) {\n if (gen.active) return { ok: false, error: '已经有一次生成在跑,先等它结束' }\n gen.active = true\n gen.reportId = report.id\n gen.chars = 0\n gen.startedAt = nowMs()\n store.meta.generating = true\n store.meta.progress = { text: '正在采集证据…', chars: 0, total: 0 }\n store.meta.lastOp = { at: nowMs(), op: 'generate', text: report.id }\n Promise.resolve()\n .then(function () { return runGenerate(report, instruction) })\n .then(function (r) {\n store.meta.progress = null\n store.meta.generating = false\n log('ok', '报告撰写完成:' + r.chars + ' 字(' + report.title + ')')\n })\n .catch(function (e) {\n store.meta.progress = null\n store.meta.generating = false\n store.meta.lastError = '撰写失败:' + msgOf(e)\n log('err', store.meta.lastError)\n dropIfEmpty(report)\n })\n .then(function () {\n gen.active = false\n return persist()\n })\n .catch(function () {})\n return { ok: true, started: true, reportId: report.id }\n }\n\n // ── 导出 ──────────────────────────────────────────────────────────────────\n // 交付方式是**写到磁盘 + 把绝对路径回显给用户**,不做浏览器下载:\n // 动态形态的客户端沙箱只给了 ctx / React / host / styles / console,\n // 没有 document / Blob / URL,拼下载链接那条路在动态半边直接不可用。\n function safeName(title) {\n const base = String(title || 'report').replace(/[\\\\/:*?\"<>|\\s]+/g, '-').replace(/^-+|-+$/g, '')\n return (base || 'report').slice(0, 80)\n }\n\n // 文本走 fs.writeText;docx 是二进制,借 shell + base64 落盘。\n // 沙箱里 shell 与插件 fs 的工作区未必相同,所以先把绝对路径解析出来再交给 shell。\n async function writeExport(name, content, isBinary) {\n const dir = String(settings().exportDir || '').trim().replace(/\\/+$/, '')\n const rel = dir ? dir + '/' + name : name\n const fs = ctx.get('fs')\n if (!isBinary) {\n if (!fs || typeof fs.resolve !== 'function') return { ok: false, error: 'fs 服务不可用' }\n const t = await fs.resolve(rel)\n await fs.writeText(t, content)\n return { ok: true, path: hostPathOf(fs, t) || rel }\n }\n const shell = ctx.get('shell')\n if (!shell || typeof shell.resolve !== 'function') return { ok: false, error: 'shell 服务不可用(Word 是二进制,落盘要借 shell + base64)' }\n let abs = rel\n if (fs && typeof fs.resolve === 'function') {\n try { abs = hostPathOf(fs, await fs.resolve(rel)) || rel } catch (e) { abs = rel }\n }\n const spec = shell.resolve({\n command: \"printf '%s' \" + shQuote(content) + ' | base64 -d > ' + shQuote(abs),\n timeoutMs: SHELL_TIMEOUT_MS, stdoutMaxBytes: 4096,\n })\n const r = await shell.run(spec)\n if (r && r.exitCode === 0) return { ok: true, path: abs }\n return { ok: false, error: '写文件失败:' + clip((r && r.stderr && r.stderr.text) || '未知', 200), path: abs }\n }\n\n function reportMetaTable(report) {\n const m = report.meta || {}\n const ev = m.evidence || {}\n return {\n '生成时间': fmtTime(m.generatedAt || report.updatedAt),\n '生成模型': (m.provider || '?') + ' / ' + (m.model || '?'),\n '证据:会话': String(ev.sessions === undefined ? '?' : ev.sessions),\n '证据:矩阵命中': '已确认 ' + (ev.matrixConfirmed || 0) + ' · 疑似 ' + (ev.matrixSuspected || 0),\n '证据:记忆条目': String(ev.memoryHits || 0) + (ev.memoryAvailable === false ? '(记忆插件未运行)' : ''),\n '字数': String(String(report.markdown || '').length),\n }\n }\n\n async function exportReport(id, format) {\n const report = store.reports.filter(function (r) { return r.id === String(id) })[0] || currentReport()\n if (!report) return { ok: false, error: '没有可导出的报告' }\n const fmt = String(format || 'md').toLowerCase()\n const name = safeName(report.title) + '-' + stamp()\n const body = String(report.markdown || '').replace(/^\\s*#\\s+.+\\n/, '')\n if (fmt === 'md') {\n const text = '# ' + report.title + '\\n\\n' + body\n const w = await writeExport(name + '.md', text, false)\n return { ok: true, format: 'md', name: name + '.md', bytes: text.length, path: w.path || '', writeError: w.ok ? null : w.error }\n }\n if (fmt === 'html') {\n const html = rptBuildHtml({ title: report.title, markdown: report.markdown, meta: reportMetaTable(report) })\n const w = await writeExport(name + '.html', html, false)\n return { ok: true, format: 'html', name: name + '.html', bytes: html.length, path: w.path || '', writeError: w.ok ? null : w.error }\n }\n if (fmt === 'docx') {\n const bytes = rptBuildDocx({ title: report.title, markdown: report.markdown, meta: reportMetaTable(report) })\n const b64 = rptBytesToBase64(bytes)\n const w = await writeExport(name + '.docx', b64, true)\n log('ok', '导出 Word:' + (w.ok ? w.path : '落盘失败(' + w.error + '),已准备浏览器下载'))\n return { ok: true, format: 'docx', name: name + '.docx', bytes: bytes.length, path: w.path || '', writeError: w.ok ? null : w.error }\n }\n return { ok: false, error: '不支持的格式:' + fmt + '(支持 md / html / docx)' }\n }\n\n // ── 导入记忆 ──────────────────────────────────────────────────────────────\n function reportToEntries(report) {\n const title = report.title || '红队报告'\n const source = '报告 · ' + clip(title, 60)\n const out = []\n for (const b of String(report.markdown || '').split(/\\n(?=##\\s)/)) {\n const t = b.trim()\n if (!t) continue\n const m = /^##\\s+(.+)$/m.exec(t)\n // 开头那段往往只有一行 H1 标题:当独立条目是噪声,跳过(但整篇没有 ## 时下面的兜底会保住全文)。\n if (!m && t.replace(/^#\\s+.+$/m, '').trim().length < 40) continue\n out.push({ title: clip(m ? m[1] : title, 120), text: t, kind: 'note', tags: ['报告'], source: source })\n }\n if (!out.length) out.push({ title: clip(title, 120), text: String(report.markdown || ''), kind: 'note', tags: ['报告'], source: source })\n return out\n }\n\n async function importToMemory(id) {\n const svc = ctx.get('redteamMemory')\n if (!svc || typeof svc.add !== 'function') {\n return { ok: false, error: '记忆插件没在运行(redteamMemory 服务不可用):先让红队记忆插件跑起来' }\n }\n const report = store.reports.filter(function (r) { return r.id === String(id) })[0] || currentReport()\n if (!report) return { ok: false, error: '没有可导入的报告' }\n if (!String(report.markdown || '').trim()) return { ok: false, error: '报告还是空的,先生成或写点内容' }\n const entries = reportToEntries(report)\n const r = await svc.add(entries, '报告 · ' + clip(report.title, 60))\n log('ok', '报告导入记忆:' + r.added + ' 条新增 / ' + r.updated + ' 条覆盖(本地共 ' + (r.localCount || 0) + ' 条' + (r.indexError ? ',索引未同步' : '') + ')')\n store.meta.lastOp = { at: nowMs(), op: 'importToMemory', text: report.title }\n return { ok: true, added: r.added, updated: r.updated, entries: entries.length, indexed: r.indexed || 0, indexError: r.indexError || null, localCount: r.localCount || 0 }\n }\n\n // ── 状态快照 ──────────────────────────────────────────────────────────────\n function reportSummary(r) {\n return {\n id: r.id, title: r.title, chars: String(r.markdown || '').length,\n createdAt: r.createdAt, updatedAt: r.updatedAt,\n provider: (r.meta && r.meta.provider) || '', model: (r.meta && r.meta.model) || '',\n evidence: (r.meta && r.meta.evidence) || null,\n }\n }\n\n function snapshot() {\n const cur = currentReport()\n const sel = pickModel()\n return {\n updatedAt: store.updatedAt,\n settings: settings(),\n outline: OUTLINE,\n model: { provider: sel.provider, model: sel.model, from: sel.from },\n reports: store.reports.map(reportSummary),\n currentId: cur ? cur.id : '',\n current: cur ? { id: cur.id, title: cur.title, markdown: cur.markdown, meta: cur.meta || {}, updatedAt: cur.updatedAt } : null,\n status: {\n persistence: store.meta.persistence || 'unknown',\n storePath: store.meta.storePath || '',\n lastError: store.meta.lastError || null,\n progress: store.meta.progress || null,\n generating: store.meta.generating === true,\n genChars: gen.chars,\n lastOp: store.meta.lastOp || null,\n evidence: store.meta.evidence || null,\n matrixPath: matrixPath(),\n },\n log: store.log.slice(-60),\n }\n }\n\n // ── 模型工具 ──────────────────────────────────────────────────────────────\n const genTool = harness.defineTool({\n name: 'report_generate',\n description: '基于当前工作区的对话、攻击矩阵命中与红队记忆库,自动撰写一份红队测试报告(Markdown)。会等写完再返回;正文同时出现在红队报告面板里,可以在那里编辑、预览、导出成 Word 或导入记忆。',\n parameters: {\n type: 'object',\n properties: {\n title: { type: 'string', description: '报告标题(省略时由正文里的 # 标题决定)' },\n instruction: { type: 'string', description: '额外要求,例如「重点写未授权访问,给出修复优先级」' },\n },\n },\n output: {\n schema: { type: 'json' },\n render: function (args, value) {\n if (!value || value.ok !== true) return [{ type: 'text', text: '撰写失败:' + ((value && value.error) || '未知错误') }]\n const e = value.evidence || {}\n const head = '报告已生成:' + value.title + '(' + value.chars + ' 字,reportId ' + value.reportId + ')'\n const ev = '证据来源:' + (e.sessions || 0) + ' 个会话 · 矩阵已确认 ' + (e.matrixConfirmed || 0) + ' 条 / 疑似 '\n + (e.matrixSuspected || 0) + ' 条(' + (e.matrixFrom === 'service' ? '来自攻击矩阵插件' : e.matrixFrom === 'file' ? '直接读矩阵存储' : '无矩阵数据') + ')· 记忆 '\n + (e.memoryHits || 0) + ' 条' + (e.memoryAvailable === false ? '(记忆插件未运行)' : '')\n const outline = (value.headings || []).length ? '章节:' + value.headings.join(' / ') : ''\n return [{ type: 'text', text: [head, ev, outline, '正文见红队报告面板(可编辑 / 预览 / 导出 Word / 导入记忆)。'].filter(Boolean).join('\\n') }]\n },\n },\n execute: async function (args) {\n await ensureLoaded()\n const a = args && typeof args === 'object' ? args : {}\n const report = newReport(clip(a.title || '', 200) || '未命名报告')\n store.reports.push(report)\n store.currentId = report.id\n try {\n const r = await runGenerate(report, a.instruction)\n await persist()\n const headings = []\n const re = /^\\s*##\\s+(.+)$/gm\n let m = re.exec(report.markdown)\n while (m) { headings.push(clip(m[1], 60)); m = re.exec(report.markdown) }\n return { ok: true, reportId: report.id, title: report.title, chars: r.chars, evidence: r.evidence, headings: headings.slice(0, 12) }\n } catch (e) {\n // 失败也别丢掉半成品:面板里能看到写到哪儿了;但一个字都没写出来时就别留空壳。\n store.meta.generating = false\n store.meta.progress = null\n log('err', '工具撰写报告失败:' + msgOf(e))\n if (!String(report.markdown || '').trim()) {\n dropIfEmpty(report)\n await persist()\n return { ok: false, error: msgOf(e), reportId: '', chars: 0 }\n }\n report.updatedAt = nowMs()\n await persist()\n return { ok: false, error: msgOf(e), reportId: report.id, chars: String(report.markdown || '').length }\n }\n },\n })\n\n const listTool = harness.defineTool({\n name: 'report_list',\n description: '列出红队报告面板里已有的报告(id、标题、字数、生成时间、证据规模)。要引用或导出某一份时先用它拿 id。',\n parameters: { type: 'object', properties: {} },\n output: {\n schema: { type: 'json' },\n render: function (args, value) {\n if (!value || value.ok !== true) return [{ type: 'text', text: '读取失败:' + ((value && value.error) || '未知错误') }]\n if (!value.reports.length) return [{ type: 'text', text: '还没有报告。可以用 report_generate 生成一份。' }]\n const lines = ['共 ' + value.reports.length + ' 份报告:']\n for (const r of value.reports) lines.push('· ' + r.title + '(' + r.chars + ' 字,id ' + r.id + ',更新于 ' + fmtTime(r.updatedAt) + ')')\n return [{ type: 'text', text: lines.join('\\n') }]\n },\n },\n execute: async function () {\n await ensureLoaded()\n return { ok: true, reports: store.reports.map(reportSummary).sort(function (a, b) { return b.updatedAt - a.updatedAt }) }\n },\n })\n\n const exportTool = harness.defineTool({\n name: 'report_export',\n description: '把红队报告导出成文件(md / html / docx 三选一),写到磁盘并返回绝对路径。要 Word 就传 format=docx。省略 reportId 时导出面板里当前那一份。',\n parameters: {\n type: 'object',\n properties: {\n reportId: { type: 'string', description: '报告 id(从 report_list 拿;省略时用当前那一份)' },\n format: { type: 'string', enum: ['md', 'html', 'docx'], description: '导出格式,默认 docx' },\n },\n },\n output: {\n schema: { type: 'json' },\n render: function (args, value) {\n if (!value || value.ok !== true) return [{ type: 'text', text: '导出失败:' + ((value && value.error) || '未知错误') }]\n const lines = ['已导出 ' + value.format + ':' + value.name + '(' + value.bytes + ' 字节)']\n lines.push(value.path ? '落盘路径:' + value.path : '(没有落盘:' + (value.writeError || '未知原因') + ')')\n if (value.writeError && value.path) lines.push('落盘失败:' + value.writeError)\n return [{ type: 'text', text: lines.join('\\n') }]\n },\n },\n execute: async function (args) {\n await ensureLoaded()\n const a = args && typeof args === 'object' ? args : {}\n const r = await exportReport(a.reportId, a.format || 'docx')\n if (r.ok) log('ok', '工具导出 ' + r.format + ':' + r.name + (r.path ? ' → ' + r.path : '(未落盘)'))\n else log('err', '工具导出失败:' + r.error)\n await persist()\n return r\n },\n })\n\n for (const t of [genTool, listTool, exportTool]) harness.registerTool(ctx, t)\n\n // ── RPC 句柄(客户端 host.call 调)────────────────────────────────────────\n harness.handle('snapshot', async function () {\n await ensureLoaded()\n return { ok: true, snapshot: snapshot() }\n })\n\n harness.handle('saveSettings', async function (args) {\n await ensureLoaded()\n mergeSettings(args && typeof args === 'object' ? args : {})\n const sel = pickModel()\n log('info', '设置已保存(模型 ' + (sel.provider || '?') + '/' + (sel.model || '?') + ',来源 ' + sel.from + ';证据上限 ' + settings().digestMax + ' 字)')\n await persist()\n return { ok: true, snapshot: snapshot() }\n })\n\n // 试算证据:把采集结果与真正喂给模型的 digest 打出来。\n // 「AI 写的报告不对」九成能从这一屏看出来:是证据没采到,还是提示词没说清。\n harness.handle('collect', async function (args) {\n await ensureLoaded()\n const ev = await collectEvidence()\n const digest = buildDigest(ev)\n const preview = Math.max(1000, Math.min(40000, intOf(args && args.preview, 8000)))\n store.meta.evidence = {\n sessions: ev.sessions.length, matrixConfirmed: ev.matrix.confirmed.length,\n matrixSuspected: ev.matrix.suspected.length, matrixFrom: ev.matrix.from,\n memoryHits: ev.memory.hits.length, memoryAvailable: ev.memory.available, digestChars: digest.length,\n }\n log('info', '试算证据:' + ev.sessions.length + ' 个会话,矩阵 ' + ev.matrix.confirmed.length + '/' + ev.matrix.suspected.length\n + ',记忆 ' + ev.memory.hits.length + ' 条,digest ' + digest.length + ' 字')\n await persist()\n return {\n ok: true, snapshot: snapshot(),\n evidence: {\n workspace: ev.workspace,\n sessions: ev.sessions.map(function (d) { return { id: d.id, title: d.title, users: d.users.length, ops: d.ops, results: d.results, firstAt: d.firstAt, lastAt: d.lastAt } }),\n matrix: { from: ev.matrix.from, storePath: ev.matrix.storePath, total: ev.matrix.total, confirmed: ev.matrix.confirmed.length, suspected: ev.matrix.suspected.length, error: ev.matrix.error || null },\n memory: { available: ev.memory.available, count: ev.memory.hits.length, note: ev.memory.note || '' },\n queries: ev.queries,\n digestChars: digest.length,\n digest: digest.slice(0, preview),\n truncated: digest.length > preview,\n },\n }\n })\n\n harness.handle('generate', async function (args) {\n await ensureLoaded()\n const a = args && typeof args === 'object' ? args : {}\n let report = a.reportId ? (store.reports.filter(function (r) { return r.id === String(a.reportId) })[0] || null) : currentReport()\n if (!report || a.createNew === true) {\n report = newReport(clip(a.title || '', 200) || '未命名报告')\n store.reports.push(report)\n }\n store.currentId = report.id\n const r = startGenerate(report, a.instruction !== undefined ? a.instruction : settings().instruction)\n if (r.ok !== true) return { ok: false, error: r.error, snapshot: snapshot() }\n await persist()\n return { ok: true, started: true, reportId: report.id, snapshot: snapshot() }\n })\n\n harness.handle('saveDraft', async function (args) {\n await ensureLoaded()\n const a = args && typeof args === 'object' ? args : {}\n const report = store.reports.filter(function (r) { return r.id === String(a.id || '') })[0] || currentReport()\n if (!report) return { ok: false, error: '没有可保存的报告' }\n if (a.title !== undefined) report.title = clip(a.title || '未命名报告', 200)\n if (a.markdown !== undefined) report.markdown = String(a.markdown || '')\n report.updatedAt = nowMs()\n store.currentId = report.id\n store.meta.lastOp = { at: nowMs(), op: 'saveDraft', text: report.title + '(' + String(report.markdown).length + ' 字)' }\n await persist()\n return { ok: true, snapshot: snapshot() }\n })\n\n harness.handle('select', async function (args) {\n await ensureLoaded()\n const id = String((args && args.id) || '')\n if (!store.reports.filter(function (r) { return r.id === id })[0]) return { ok: false, error: '找不到这份报告' }\n store.currentId = id\n await persist()\n return { ok: true, snapshot: snapshot() }\n })\n\n harness.handle('create', async function (args) {\n await ensureLoaded()\n const report = newReport(clip((args && args.title) || '', 200) || '未命名报告')\n store.reports.push(report)\n store.currentId = report.id\n log('info', '新建报告:' + report.title)\n await persist()\n return { ok: true, id: report.id, snapshot: snapshot() }\n })\n\n harness.handle('remove', async function (args) {\n await ensureLoaded()\n const ids = Array.isArray(args && args.ids) ? args.ids.map(String).filter(Boolean) : []\n if (!ids.length) return { ok: false, error: '没有选中要删除的报告' }\n const kept = []\n let n = 0\n for (const r of store.reports) {\n if (ids.indexOf(r.id) >= 0) { n++; continue }\n kept.push(r)\n }\n store.reports = kept\n if (ids.indexOf(store.currentId) >= 0) store.currentId = kept.length ? kept[kept.length - 1].id : ''\n log('warn', '删除报告 ' + n + ' 份')\n await persist()\n return { ok: true, deleted: n, snapshot: snapshot() }\n })\n\n // 预览:返回**完整 HTML 文档**,客户端塞进 iframe.srcdoc —— 与导出的 HTML 是同一份实现,\n // 所以「预览看到的」就是「导出的」。不用在前端再写一遍 markdown 渲染。\n harness.handle('preview', async function (args) {\n await ensureLoaded()\n const a = args && typeof args === 'object' ? args : {}\n const report = store.reports.filter(function (r) { return r.id === String(a.id || '') })[0] || currentReport()\n const markdown = a.markdown !== undefined ? String(a.markdown || '') : String((report && report.markdown) || '')\n const title = (a.title !== undefined ? String(a.title || '') : String((report && report.title) || '')) || '红队报告'\n return { ok: true, html: rptBuildHtml({ title: title, markdown: markdown, meta: report ? reportMetaTable(report) : {} }), chars: markdown.length }\n })\n\n harness.handle('export', async function (args) {\n await ensureLoaded()\n const a = args && typeof args === 'object' ? args : {}\n const r = await exportReport(a.id, a.format)\n if (r.ok) log('ok', '导出 ' + r.format + ':' + r.name + (r.path ? ' → ' + r.path : '(未落盘,走浏览器下载)'))\n else log('err', '导出失败:' + r.error)\n await persist()\n return r\n })\n\n harness.handle('importToMemory', async function (args) {\n await ensureLoaded()\n const a = args && typeof args === 'object' ? args : {}\n try {\n const r = await importToMemory(a.id)\n await persist()\n return Object.assign({ snapshot: snapshot() }, r)\n } catch (e) {\n log('err', '导入记忆失败:' + msgOf(e))\n await persist()\n return { ok: false, error: msgOf(e), snapshot: snapshot() }\n }\n })\n\n harness.handle('logClear', async function () {\n store.log = []\n await persist()\n return { ok: true, snapshot: snapshot() }\n })\n\n ensureLoaded()\n .then(function () {\n console.log('[rtreport] 报告库 ' + store.reports.length + ' 份,落盘 ' + (store.meta.persistence || '?') + ':' + (store.meta.storePath || '(未解析)'))\n })\n .catch(function (e) { console.error('[rtreport] load failed:', msgOf(e)) })\n .then(function () { loaded = true })\n\n console.log('[rtreport] redteam-report host half ready; tools = 3, outline =', OUTLINE.length, '节,导出格式 md/html/docx')\n\n/* @DOCX@ */\n", docxSource: "// 红队报告 · Markdown → 自包含 HTML / 真·DOCX(纯 JS,零依赖)\n//\n// 为什么单独成文件:这部分逻辑有测试价值,而 src/host.js 只是 applyHost 的「函数体片段」,\n// 没法被测试 import。本文件按仓库约定写成 ESM,顶层只用 `export function` / `export const`,\n// tools/build-lib.mjs 拼接时剥掉行首的 `export `,于是同一份源码既能被 node 测试 import,\n// 也能原样落进 lib/host.js 的函数体。\n//\n// 由此得出三条不能破的约束(改这里之前先读一遍):\n// 1. 不 import / 不 require / 不 export default / 不写 `export { a, b }` 聚合导出;\n// 2. 所有顶层名字带 rpt / RPT_ 前缀 —— 它们会和宿主函数体里已有的局部变量同处一个作用域;\n// 3. 不依赖 btoa / Buffer / TextEncoder / DOM —— 宿主沙箱里这些不保证存在,\n// 所以 UTF-8 编码与 base64 都是手写实现,ZIP 与 CRC-32 也是。\n//\n// 三份输出(块结构 / HTML / DOCX)共用同一个 rptParseMarkdown,行内格式只解析一次,\n// 这样「网页预览」与「Word 交付件」不可能出现粗体、链接对不上的情况。\n\n// 报告 HTML 的内联样式。与 rptBuildHtml 同源,导出是为了让调用方(面板预览)能只取样式。\nexport const RPT_REPORT_STYLES = `\n:root { color-scheme: light; }\n* { box-sizing: border-box; }\nbody {\n margin: 0; padding: 40px 24px; background: #f5f6f8; color: #1f2329;\n font-family: -apple-system, \"PingFang SC\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Arial, sans-serif;\n font-size: 15px; line-height: 1.75;\n}\n.rpt { max-width: 880px; margin: 0 auto; background: #fff; padding: 48px 56px 64px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }\n.rpt-title { margin: 0 0 6px; font-size: 30px; line-height: 1.35; font-weight: 700; letter-spacing: .5px; }\n.rpt-meta { width: 100%; border-collapse: collapse; margin: 18px 0 30px; font-size: 14px; }\n.rpt-meta th, .rpt-meta td { border: 1px solid #d0d5dd; padding: 7px 10px; text-align: left; }\n.rpt-meta th { width: 8em; background: #f2f4f7; font-weight: 600; white-space: nowrap; }\nh1, h2, h3, h4 { line-height: 1.4; margin: 28px 0 12px; font-weight: 700; }\nh1 { font-size: 24px; } h2 { font-size: 20px; } h3 { font-size: 17px; } h4 { font-size: 15px; }\np { margin: 12px 0; }\n.rpt-list { margin: 12px 0; padding-left: 26px; }\n.rpt-list li { margin: 4px 0; }\n.rpt-code {\n margin: 16px 0; padding: 14px 16px; overflow: auto; white-space: pre; border-radius: 6px;\n background: #1f2329; color: #e8e8e8; font-size: 13px; line-height: 1.6;\n font-family: Consolas, \"SFMono-Regular\", Menlo, Consolas, monospace;\n}\n.rpt-code code { background: none; color: inherit; padding: 0; font-size: inherit; }\ncode { background: #f0f1f3; padding: 1px 5px; border-radius: 4px; font-size: .92em; font-family: Consolas, Menlo, monospace; }\nblockquote { margin: 16px 0; padding: 8px 16px; border-left: 4px solid #9aa4b2; background: #f7f8fa; color: #4b5563; }\nblockquote p { margin: 0; }\n.rpt-table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 14px; }\n.rpt-table th, .rpt-table td { border: 1px solid #d0d5dd; padding: 8px 10px; text-align: left; vertical-align: top; }\n.rpt-table thead th { background: #f2f4f7; font-weight: 600; }\na { color: #1a56db; }\n@media print {\n body { background: #fff; padding: 0; }\n .rpt { box-shadow: none; max-width: none; padding: 0; }\n}\n`\n\n// base64 字母表(手写实现用,避免 btoa/Buffer)\nconst RPT_BASE64_TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n\n// ZIP 里所有条目都用固定时间戳:同一份 markdown 必须产出逐字节相同的 docx,\n// 否则「产物比对」这类测试与缓存都会失效。\nconst RPT_DOS_TIME = 0\nconst RPT_DOS_DATE = 0x21 // 1980-01-01\n\n// 超链接关系从 rId2 起编号:rId1 固定留给 styles.xml。\nconst RPT_FIRST_LINK_RID = 2\n\n// ── 编码原语 ────────────────────────────────────────────────────────────────\n\n// 手写 UTF-8:宿主沙箱里没有 TextEncoder,而且必须正确处理 emoji 的代理对。\n// 内容里的孤立代理项会产出非法 UTF-8 字节,调用方(转义层)负责先过滤掉。\nexport function rptUtf8Encode(str) {\n const s = String(str == null ? '' : str)\n const out = []\n for (let i = 0; i < s.length; i++) {\n let cp = s.charCodeAt(i)\n if (cp >= 0xd800 && cp <= 0xdbff && i + 1 < s.length) {\n const lo = s.charCodeAt(i + 1)\n // 高低代理项配对后是一个 BMP 之外的码点,占 4 字节\n if (lo >= 0xdc00 && lo <= 0xdfff) {\n cp = 0x10000 + ((cp - 0xd800) << 10) + (lo - 0xdc00)\n i++\n }\n }\n if (cp < 0x80) out.push(cp)\n else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f))\n else if (cp < 0x10000) out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f))\n else out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f))\n }\n return Uint8Array.from(out)\n}\n\n// 手写 base64:不换行,标准 +/ 字母表,末组补 =。\nexport function rptBytesToBase64(bytes) {\n const b = bytes || []\n const n = b.length\n let out = ''\n let i = 0\n for (; i + 3 <= n; i += 3) {\n const v = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2]\n out += RPT_BASE64_TABLE[(v >>> 18) & 63] + RPT_BASE64_TABLE[(v >>> 12) & 63] +\n RPT_BASE64_TABLE[(v >>> 6) & 63] + RPT_BASE64_TABLE[v & 63]\n }\n const rest = n - i\n if (rest === 1) {\n const v = b[i] << 16\n out += RPT_BASE64_TABLE[(v >>> 18) & 63] + RPT_BASE64_TABLE[(v >>> 12) & 63] + '=='\n } else if (rest === 2) {\n const v = (b[i] << 16) | (b[i + 1] << 8)\n out += RPT_BASE64_TABLE[(v >>> 18) & 63] + RPT_BASE64_TABLE[(v >>> 12) & 63] +\n RPT_BASE64_TABLE[(v >>> 6) & 63] + '='\n }\n return out\n}\n\n// ── 文本与行内格式 ──────────────────────────────────────────────────────────\n\n// HTML 转义。markdown 是外部输入(可能来自目标站点或抓取结果),\n// 不转义就等于把「报告里的 <script>」直接变成活代码,所以这里连同引号一起转。\nfunction rptEscapeHtml(text) {\n return String(text == null ? '' : text)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\n// 剔掉 XML 1.0 不允许的字符(除 \\t \\n \\r 外的 C0 控制符、孤立代理项、0xFFFE/0xFFFF)。\n// 不做这步 Word 会直接报「文档已损坏」——它比标签配对错误更难定位。\nfunction rptXmlClean(text) {\n const s = String(text == null ? '' : text)\n let out = ''\n for (let i = 0; i < s.length; i++) {\n const c = s.charCodeAt(i)\n if (c === 0x9 || c === 0xa || c === 0xd || (c >= 0x20 && c <= 0xd7ff) || (c >= 0xe000 && c <= 0xfffd)) {\n out += s[i]\n } else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {\n const lo = s.charCodeAt(i + 1)\n if (lo >= 0xdc00 && lo <= 0xdfff) { out += s[i] + s[i + 1]; i++ }\n }\n }\n return out\n}\n\n// 引号也转:同一个函数同时用于文本节点与 r:id/链接 Target 等属性值。\nfunction rptXmlEscape(text) {\n return rptXmlClean(text)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n}\n\n// 只放行明确安全的协议:报告里的链接同样来自外部数据,\n// javascript:/data: 这类在 Word 与浏览器里都是可执行面,一律降级成纯文本。\nfunction rptSafeUrl(url) {\n const u = String(url == null ? '' : url).replace(/[\\u0000-\\u0020\\u007f]/g, '').trim()\n if (u === '') return ''\n const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(u)\n if (!m) return u // 相对路径\n const scheme = m[1].toLowerCase()\n return (scheme === 'http' || scheme === 'https' || scheme === 'mailto' || scheme === 'ftp') ? u : ''\n}\n\nfunction rptRunsToText(runs) {\n let out = ''\n for (const r of runs || []) out += r && r.text != null ? r.text : ''\n return out\n}\n\n// 行内解析:**粗体** / `行内代码` / [文字](url)。\n// 刻意不做嵌套(单趟扫描,非重叠匹配)——报告里的行内格式几乎不会嵌套,\n// 而支持嵌套会让 HTML 与 DOCX 两条渲染路径的分支数翻倍、更难保持一致。\nfunction rptParseInline(text) {\n const src = String(text == null ? '' : text)\n const runs = []\n const re = /\\*\\*([\\s\\S]+?)\\*\\*|`([^`]+)`|\\[([^\\]]*)\\]\\(([^)\\s]*)(?:\\s+\"[^\"]*\")?\\)/g\n let last = 0\n let m\n while ((m = re.exec(src)) !== null) {\n if (m.index > last) runs.push({ text: src.slice(last, m.index) })\n if (m[1] !== undefined) runs.push({ text: m[1], bold: true })\n else if (m[2] !== undefined) runs.push({ text: m[2], code: true })\n else runs.push({ text: m[3], href: rptSafeUrl(m[4]) })\n last = m.index + m[0].length\n }\n if (last < src.length) runs.push({ text: src.slice(last) })\n if (runs.length === 0) runs.push({ text: '' })\n return runs\n}\n\n// text 一律是「去掉行内标记的纯文本」,runs 才是结构化形式:\n// 调用方想直接拿文本做检索/摘要时不该还看到 ** 和 [](),而两条渲染路径都只吃 runs。\nfunction rptTextBlock(type, text, ordered) {\n const runs = rptParseInline(text)\n const block = { type: type, text: rptRunsToText(runs), runs: runs }\n if (ordered !== undefined) block.ordered = ordered\n return block\n}\n\n// ── Markdown 块解析 ─────────────────────────────────────────────────────────\n\nfunction rptSplitRow(line) {\n let t = String(line).trim()\n if (t.charAt(0) === '|') t = t.slice(1)\n if (t.charAt(t.length - 1) === '|') t = t.slice(0, -1)\n return t.split('|').map(function (c) { return c.trim() })\n}\n\nfunction rptIsTableSeparator(line) {\n const t = String(line).trim()\n if (t.indexOf('-') < 0) return false\n if (!/^\\|?[\\s:|-]+\\|?$/.test(t)) return false\n const cells = rptSplitRow(t)\n if (cells.length === 0) return false\n for (const c of cells) if (!/^:?-+:?$/.test(c.replace(/\\s/g, ''))) return false\n return true\n}\n\nfunction rptIsFence(line) {\n return /^(```|~~~)/.test(String(line).trim())\n}\n\nfunction rptIsHeading(line) {\n return /^#{1,6}\\s+/.test(String(line).trim())\n}\n\nfunction rptIsList(line) {\n const t = String(line).trim()\n return /^[-*]\\s+/.test(t) || /^\\d+[.)]\\s+/.test(t)\n}\n\nfunction rptIsQuote(line) {\n return /^\\s*>\\s?/.test(String(line))\n}\n\n// 段落收集时的「下一行是否另起块」判断:段落与表格行都以 | 出现,只能靠后一行是不是分隔行区分。\nfunction rptStartsNewBlock(lines, i) {\n const line = lines[i]\n if (rptIsHeading(line) || rptIsFence(line) || rptIsList(line) || rptIsQuote(line)) return true\n const t = String(line).trim()\n if (t.indexOf('|') >= 0 && i + 1 < lines.length && rptIsTableSeparator(lines[i + 1])) return true\n return false\n}\n\nfunction rptTableRows(rows) {\n let width = 0\n for (const r of rows) if (r.length > width) width = r.length\n const out = []\n for (const r of rows) {\n const cells = r.slice()\n while (cells.length < width) cells.push('')\n out.push(cells)\n }\n return out\n}\n\n// markdown → 结构化块。返回块类型:\n// h1..h4 | p | li | oli | code | quote | table\n// 段落/列表项给出 text(纯文本)与 runs(行内结构);表格给出 rows(纯文本)、\n// cellRuns(每格的行内结构)与 text(表头行)。DOCX 与 HTML 都只消费 runs/cellRuns,\n// 因此「网页预览」与「Word 交付件」在粗体、链接上不可能对不上。\nexport function rptParseMarkdown(md) {\n const lines = String(md == null ? '' : md).replace(/\\r\\n?/g, '\\n').split('\\n')\n const blocks = []\n let i = 0\n while (i < lines.length) {\n const raw = lines[i]\n const trimmed = raw.trim()\n if (trimmed === '') { i++; continue }\n\n // 围栏代码块:内容逐字保留,语言标记丢弃(两种输出都不做语法高亮)\n const fence = /^(```|~~~)/.exec(trimmed)\n if (fence) {\n const mark = fence[1]\n const body = []\n i++\n while (i < lines.length && lines[i].trim().indexOf(mark) !== 0) { body.push(lines[i]); i++ }\n if (i < lines.length) i++ // 吃掉收尾围栏;未闭合时就是到文件末尾\n blocks.push({ type: 'code', lines: body, text: body.join('\\n') })\n continue\n }\n\n const heading = /^(#{1,6})\\s+(.*)$/.exec(trimmed)\n if (heading) {\n // #### 及以上统一按 h4:报告排版到四级标题就够,再深也只是字号差异\n const level = Math.min(heading[1].length, 4)\n blocks.push(rptTextBlock('h' + level, heading[2].trim()))\n i++\n continue\n }\n\n // 表格必须先于段落判断:表格行同样是普通文本行,只有「下一行是分隔行」能区分\n if (trimmed.indexOf('|') >= 0 && i + 1 < lines.length && rptIsTableSeparator(lines[i + 1])) {\n const rows = [rptSplitRow(trimmed)]\n i += 2\n while (i < lines.length && lines[i].trim() !== '' && lines[i].trim().indexOf('|') >= 0) {\n rows.push(rptSplitRow(lines[i].trim()))\n i++\n }\n const normalized = rptTableRows(rows)\n const cellRuns = normalized.map(function (r) { return r.map(rptParseInline) })\n const plain = cellRuns.map(function (r) { return r.map(rptRunsToText) })\n blocks.push({\n type: 'table',\n rows: plain,\n cellRuns: cellRuns,\n text: plain[0].join(' | '),\n })\n continue\n }\n\n if (rptIsQuote(raw)) {\n const parts = []\n while (i < lines.length && rptIsQuote(lines[i])) {\n parts.push(lines[i].replace(/^\\s*>\\s?/, ''))\n i++\n }\n blocks.push(rptTextBlock('quote', parts.join(' ').trim()))\n continue\n }\n\n const ul = /^[-*]\\s+(.*)$/.exec(trimmed)\n if (ul) {\n blocks.push(rptTextBlock('li', ul[1].trim(), false))\n i++\n continue\n }\n\n const ol = /^\\d+[.)]\\s+(.*)$/.exec(trimmed)\n if (ol) {\n blocks.push(rptTextBlock('oli', ol[1].trim(), true))\n i++\n continue\n }\n\n // 普通段落:连续非空行合并为一段,段内换行按 markdown 语义转成空格\n const para = []\n while (i < lines.length && lines[i].trim() !== '' && !rptStartsNewBlock(lines, i)) {\n para.push(lines[i].trim())\n i++\n }\n blocks.push(rptTextBlock('p', para.join(' ')))\n }\n return blocks\n}\n\n// ── HTML 渲染 ───────────────────────────────────────────────────────────────\n\nfunction rptRunsHtml(runs) {\n let out = ''\n for (const r of runs || []) {\n const text = rptEscapeHtml(r && r.text != null ? r.text : '')\n if (r && r.code) out += '<code>' + text + '</code>'\n else if (r && r.href) out += '<a href=\"' + rptEscapeHtml(r.href) + '\" rel=\"noopener noreferrer\">' + text + '</a>'\n else if (r && r.bold) out += '<strong>' + text + '</strong>'\n else out += text\n }\n return out\n}\n\nfunction rptCellRuns(cell) {\n // rptParseMarkdown 已经给出 cellRuns;这里只兜底手工构造的 rows\n if (Array.isArray(cell)) return cell\n return rptParseInline(cell)\n}\n\nfunction rptTableHtml(rows, headerRow) {\n const body = (rows || []).map(function (row, ri) {\n const tag = headerRow && ri === 0 ? 'th' : 'td'\n const cells = row.map(function (c) { return '<' + tag + '>' + rptRunsHtml(rptCellRuns(c)) + '</' + tag + '>' })\n return '<tr>' + cells.join('') + '</tr>'\n })\n if (body.length === 0) return ''\n const head = headerRow && body.length > 0 ? '<thead>' + body[0] + '</thead>' : ''\n const tail = (headerRow ? body.slice(1) : body).join('')\n return '<table class=\"rpt-table\">' + head + (tail ? '<tbody>' + tail + '</tbody>' : '') + '</table>'\n}\n\nfunction rptMetaHtml(meta) {\n const keys = meta ? Object.keys(meta) : []\n if (keys.length === 0) return ''\n const rows = keys.map(function (k) {\n return '<tr><th>' + rptEscapeHtml(k) + '</th><td>' + rptEscapeHtml(String(meta[k])) + '</td></tr>'\n })\n return '<table class=\"rpt-meta\"><tbody>' + rows.join('') + '</tbody></table>'\n}\n\nfunction rptBlocksHtml(blocks) {\n const out = []\n for (let i = 0; i < blocks.length; i++) {\n const b = blocks[i]\n if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3' || b.type === 'h4') {\n out.push('<' + b.type + '>' + rptRunsHtml(b.runs) + '</' + b.type + '>')\n continue\n }\n if (b.type === 'p') { out.push('<p>' + rptRunsHtml(b.runs) + '</p>'); continue }\n if (b.type === 'quote') { out.push('<blockquote><p>' + rptRunsHtml(b.runs) + '</p></blockquote>'); continue }\n if (b.type === 'code') {\n out.push('<pre class=\"rpt-code\"><code>' + rptEscapeHtml((b.lines || []).join('\\n')) + '</code></pre>')\n continue\n }\n if (b.type === 'table') {\n out.push(rptTableHtml(b.cellRuns || b.rows || [], true))\n continue\n }\n if (b.type === 'li' || b.type === 'oli') {\n // 相邻同类列表项合并成一个 <ul>/<ol>,否则每项都会被迫套一层列表\n const tag = b.type === 'oli' ? 'ol' : 'ul'\n const items = []\n let j = i\n while (j < blocks.length && blocks[j].type === b.type) {\n items.push('<li>' + rptRunsHtml(blocks[j].runs) + '</li>')\n j++\n }\n out.push('<' + tag + ' class=\"rpt-list\">' + items.join('') + '</' + tag + '>')\n i = j - 1\n continue\n }\n }\n return out.join('\\n')\n}\n\n// markdown → 完整自包含 HTML(无外部资源,可直接进浏览器或另存为 .html)。\n// 必须是完整文档而不是片段:交付时经常直接 base64 内联或写盘双击打开,\n// 片段在那种场景下会因缺 <meta charset> 而把中文显示成乱码。\nexport function rptBuildHtml(options) {\n const opts = options || {}\n const title = String(opts.title == null ? '' : opts.title)\n const meta = opts.meta && typeof opts.meta === 'object' ? opts.meta : null\n const blocks = rptParseMarkdown(opts.markdown == null ? '' : opts.markdown)\n const parts = [\n '<!doctype html>',\n '<html lang=\"zh-CN\">',\n '<head>',\n '<meta charset=\"utf-8\">',\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">',\n '<title>' + rptEscapeHtml(title) + '</title>',\n '<style>' + RPT_REPORT_STYLES + '</style>',\n '</head>',\n '<body>',\n '<article class=\"rpt\">',\n '<h1 class=\"rpt-title\">' + rptEscapeHtml(title) + '</h1>',\n rptMetaHtml(meta),\n rptBlocksHtml(blocks),\n '</article>',\n '</body>',\n '</html>',\n ]\n return parts.filter(function (p) { return p !== '' }).join('\\n') + '\\n'\n}\n\n// ── DOCX(OOXML)渲染 ───────────────────────────────────────────────────────\n\n// 链接关系表:同一 URL 只建一条关系(Word 对重复 rId 目标不报错,但产物会无谓膨胀)。\n// links 是 { id, target } 数组,渲染 document.xml 时按需追加。\nfunction rptLinkId(links, target) {\n for (const l of links) if (l.target === target) return l.id\n const id = 'rId' + (links.length + RPT_FIRST_LINK_RID)\n links.push({ id: id, target: target })\n return id\n}\n\nfunction rptDocxRun(run, links, mono) {\n const r = run || {}\n const rPr = []\n if (mono || r.code) rPr.push('<w:rFonts w:ascii=\"Consolas\" w:hAnsi=\"Consolas\"/>')\n if (r.bold) rPr.push('<w:b/>')\n if (r.href) rPr.push('<w:color w:val=\"0563C1\"/><w:u w:val=\"single\"/>')\n const text = '<w:t xml:space=\"preserve\">' + rptXmlEscape(r.text == null ? '' : r.text) + '</w:t>'\n const inner = '<w:r>' + (rPr.length ? '<w:rPr>' + rPr.join('') + '</w:rPr>' : '') + text + '</w:r>'\n if (r.href) {\n const id = rptLinkId(links, r.href)\n return '<w:hyperlink r:id=\"' + id + '\">' + inner + '</w:hyperlink>'\n }\n return inner\n}\n\nfunction rptDocxRuns(runs, links, mono) {\n let out = ''\n for (const r of runs || []) out += rptDocxRun(r, links, mono)\n return out\n}\n\nfunction rptDocxPara(style, runs, links) {\n const body = rptDocxRuns(runs, links, false)\n const pPr = style ? '<w:pPr><w:pStyle w:val=\"' + style + '\"/></w:pPr>' : ''\n if (body === '') return '<w:p>' + pPr + '</w:p>'\n return '<w:p>' + pPr + body + '</w:p>'\n}\n\n// 代码块:每行一个等宽段落。用段落而不是 <w:br/>,因为报告里的行号/缩进对齐\n// 在段落下更稳,且复制到别处时仍是按行的。\nfunction rptDocxCode(lines, links) {\n const out = []\n const src = lines && lines.length ? lines : ['']\n for (const line of src) {\n const run = line === '' ? '' : rptDocxRun({ text: line }, links, true)\n out.push('<w:p><w:pPr><w:pStyle w:val=\"Code\"/></w:pPr>' + run + '</w:p>')\n }\n return out.join('')\n}\n\nconst RPT_TBL_BORDERS =\n '<w:tblBorders>' +\n '<w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>' +\n '<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>' +\n '<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>' +\n '<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>' +\n '<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>' +\n '<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>' +\n '</w:tblBorders>'\n\nfunction rptDocxTable(rows, links, headerBold, totalWidth) {\n const src = rows || []\n if (src.length === 0) return ''\n let cols = 0\n for (const r of src) if (r.length > cols) cols = r.length\n if (cols === 0) return ''\n const colW = Math.floor((totalWidth || 9000) / cols)\n const grid = []\n for (let c = 0; c < cols; c++) grid.push('<w:gridCol w:w=\"' + colW + '\"/>')\n const trs = []\n for (let ri = 0; ri < src.length; ri++) {\n const row = src[ri]\n const tcs = []\n for (let ci = 0; ci < cols; ci++) {\n const cell = row[ci]\n let runs = rptCellRuns(cell === undefined ? '' : cell)\n if (headerBold && ri === 0) runs = runs.map(function (r) { return { text: r.text, bold: true, code: r.code, href: r.href } })\n // 单元格至少要有一个块级元素,空 <w:tc/> 会让 Word 判为损坏\n const para = '<w:p>' + rptDocxRuns(runs, links, false) + '</w:p>'\n tcs.push('<w:tc><w:tcPr><w:tcW w:w=\"' + colW + '\" w:type=\"dxa\"/></w:tcPr>' + para + '</w:tc>')\n }\n trs.push('<w:tr>' + tcs.join('') + '</w:tr>')\n }\n return '<w:tbl><w:tblPr><w:tblW w:w=\"' + (totalWidth || 9000) + '\" w:type=\"dxa\"/>' + RPT_TBL_BORDERS +\n '</w:tblPr><w:tblGrid>' + grid.join('') + '</w:tblGrid>' + trs.join('') + '</w:tbl>'\n}\n\n// styles.xml 里所有 w:pStyle 引用到的样式都必须在这里有定义,\n// 否则 Word 会按「样式不存在」处理(内容还在,但版式静默丢失)。\nconst RPT_STYLES_XML = '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>' +\n '<w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">' +\n '<w:docDefaults><w:rPrDefault><w:rPr>' +\n '<w:rFonts w:ascii=\"Calibri\" w:hAnsi=\"Calibri\"/><w:sz w:val=\"21\"/><w:szCs w:val=\"21\"/>' +\n '</w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after=\"120\" w:line=\"320\" w:lineRule=\"auto\"/></w:pPr></w:pPrDefault></w:docDefaults>' +\n '<w:style w:type=\"paragraph\" w:default=\"1\" w:styleId=\"Normal\"><w:name w:val=\"Normal\"/><w:qFormat/></w:style>' +\n '<w:style w:type=\"paragraph\" w:styleId=\"Title\"><w:name w:val=\"Title\"/><w:basedOn w:val=\"Normal\"/><w:qFormat/>' +\n '<w:pPr><w:jc w:val=\"center\"/><w:outlineLvl w:val=\"0\"/></w:pPr><w:rPr><w:b/><w:sz w:val=\"44\"/><w:szCs w:val=\"44\"/></w:rPr></w:style>' +\n '<w:style w:type=\"paragraph\" w:styleId=\"Heading1\"><w:name w:val=\"heading 1\"/><w:basedOn w:val=\"Normal\"/><w:qFormat/>' +\n '<w:pPr><w:outlineLvl w:val=\"0\"/></w:pPr><w:rPr><w:b/><w:sz w:val=\"32\"/><w:szCs w:val=\"32\"/></w:rPr></w:style>' +\n '<w:style w:type=\"paragraph\" w:styleId=\"Heading2\"><w:name w:val=\"heading 2\"/><w:basedOn w:val=\"Normal\"/><w:qFormat/>' +\n '<w:pPr><w:outlineLvl w:val=\"1\"/></w:pPr><w:rPr><w:b/><w:sz w:val=\"28\"/><w:szCs w:val=\"28\"/></w:rPr></w:style>' +\n '<w:style w:type=\"paragraph\" w:styleId=\"Heading3\"><w:name w:val=\"heading 3\"/><w:basedOn w:val=\"Normal\"/><w:qFormat/>' +\n '<w:pPr><w:outlineLvl w:val=\"2\"/></w:pPr><w:rPr><w:b/><w:sz w:val=\"24\"/><w:szCs w:val=\"24\"/></w:rPr></w:style>' +\n '<w:style w:type=\"paragraph\" w:styleId=\"Heading4\"><w:name w:val=\"heading 4\"/><w:basedOn w:val=\"Normal\"/><w:qFormat/>' +\n '<w:pPr><w:outlineLvl w:val=\"3\"/></w:pPr><w:rPr><w:b/><w:sz w:val=\"22\"/><w:szCs w:val=\"22\"/></w:rPr></w:style>' +\n '<w:style w:type=\"paragraph\" w:styleId=\"Code\"><w:name w:val=\"Report Code\"/><w:basedOn w:val=\"Normal\"/>' +\n '<w:pPr><w:spacing w:after=\"0\" w:line=\"240\" w:lineRule=\"auto\"/><w:shd w:val=\"clear\" w:color=\"auto\" w:fill=\"F5F5F5\"/></w:pPr>' +\n '<w:rPr><w:rFonts w:ascii=\"Consolas\" w:hAnsi=\"Consolas\"/><w:sz w:val=\"18\"/><w:szCs w:val=\"18\"/></w:rPr></w:style>' +\n '<w:style w:type=\"paragraph\" w:styleId=\"Quote\"><w:name w:val=\"Report Quote\"/><w:basedOn w:val=\"Normal\"/>' +\n '<w:pPr><w:ind w:left=\"420\"/></w:pPr><w:rPr><w:i/><w:color w:val=\"4B5563\"/></w:rPr></w:style>' +\n '</w:styles>'\n\nconst RPT_CONTENT_TYPES_XML = '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>' +\n '<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">' +\n '<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>' +\n '<Default Extension=\"xml\" ContentType=\"application/xml\"/>' +\n '<Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/>' +\n '<Override PartName=\"/word/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml\"/>' +\n '</Types>'\n\nconst RPT_ROOT_RELS_XML = '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>' +\n '<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">' +\n '<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/>' +\n '</Relationships>'\n\nconst RPT_DOC_RELS_HEAD = '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>' +\n '<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">' +\n '<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>'\n\n// 文档级关系:正文里的每个外链都要在这里登记一条 TargetMode=\"External\" 的关系,\n// 否则 <w:hyperlink r:id> 指向不存在的 rId,Word 会丢掉链接(或被判为损坏)。\nfunction rptDocRelsXml(links) {\n let out = RPT_DOC_RELS_HEAD\n for (const l of links) {\n out += '<Relationship Id=\"' + l.id + '\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink\" Target=\"' +\n rptXmlEscape(l.target) + '\" TargetMode=\"External\"/>'\n }\n return out + '</Relationships>'\n}\n\nconst RPT_SECT_PR =\n '<w:sectPr><w:pgSz w:w=\"11906\" w:h=\"16838\"/>' +\n '<w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\" w:header=\"851\" w:footer=\"992\" w:gutter=\"0\"/>' +\n '</w:sectPr>'\n\n// markdown → 真·Word .docx 字节流。返回 Uint8Array(未压缩 ZIP,纯 JS 写)。\nexport function rptBuildDocx(options) {\n const opts = options || {}\n const title = String(opts.title == null ? '' : opts.title)\n const meta = opts.meta && typeof opts.meta === 'object' ? opts.meta : null\n const blocks = rptParseMarkdown(opts.markdown == null ? '' : opts.markdown)\n const links = []\n const parts = []\n\n if (title !== '') parts.push(rptDocxPara('Title', [{ text: title }], links))\n\n if (meta) {\n const keys = Object.keys(meta)\n if (keys.length > 0) {\n const rows = keys.map(function (k) { return [k, String(meta[k])] })\n parts.push(rptDocxTable(rows, links, true, 9000))\n }\n }\n\n let listIndex = 0\n let prevType = ''\n let lastWasTable = false\n for (const b of blocks) {\n if (b.type !== prevType) listIndex = 0\n prevType = b.type\n if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3' || b.type === 'h4') {\n parts.push(rptDocxPara('Heading' + b.type.slice(1), b.runs, links))\n } else if (b.type === 'p') {\n parts.push(rptDocxPara('Normal', b.runs, links))\n } else if (b.type === 'quote') {\n parts.push(rptDocxPara('Quote', b.runs, links))\n } else if (b.type === 'li') {\n listIndex++\n // 真项目符号要 numbering.xml + 额外的 content-type/关系,为一份交付用报告不值得;\n // 直接把符号写进文本,Word 里看起来一样。\n parts.push(rptDocxPara('Normal', [{ text: '• ' }].concat(b.runs), links))\n } else if (b.type === 'oli') {\n listIndex++\n parts.push(rptDocxPara('Normal', [{ text: listIndex + '. ' }].concat(b.runs), links))\n } else if (b.type === 'code') {\n parts.push(rptDocxCode(b.lines || [], links))\n } else if (b.type === 'table') {\n parts.push(rptDocxTable(b.cellRuns || b.rows || [], links, true, 9000))\n }\n lastWasTable = b.type === 'table'\n }\n\n // OOXML 规定正文最后一个块不能是表格(表格后必须跟段落,否则 Word 判损坏)\n if (lastWasTable) parts.push('<w:p/>')\n\n const documentXml = '<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>' +\n '<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" ' +\n 'xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">' +\n '<w:body>' + parts.join('') + RPT_SECT_PR + '</w:body></w:document>'\n\n return rptZipBuild([\n { name: '[Content_Types].xml', data: rptUtf8Encode(RPT_CONTENT_TYPES_XML) },\n { name: '_rels/.rels', data: rptUtf8Encode(RPT_ROOT_RELS_XML) },\n { name: 'word/_rels/document.xml.rels', data: rptUtf8Encode(rptDocRelsXml(links)) },\n { name: 'word/document.xml', data: rptUtf8Encode(documentXml) },\n { name: 'word/styles.xml', data: rptUtf8Encode(RPT_STYLES_XML) },\n ])\n}\n\n// ── ZIP(stored,无压缩) ───────────────────────────────────────────────────\n\n// 表驱动 CRC-32(IEEE 反射多项式 0xEDB88320)。表只建一次,报告里常有几十个条目。\nlet rptCrcTable = null\nfunction rptCrc32(bytes) {\n if (rptCrcTable === null) {\n const t = []\n for (let n = 0; n < 256; n++) {\n let c = n\n for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)\n t.push(c >>> 0)\n }\n rptCrcTable = t\n }\n let crc = 0xffffffff\n for (let i = 0; i < bytes.length; i++) crc = rptCrcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8)\n return (crc ^ 0xffffffff) >>> 0\n}\n\n// ZIP 里所有多字节整数都是小端\nfunction rptPutU16(arr, at, value) {\n arr[at] = value & 0xff\n arr[at + 1] = (value >>> 8) & 0xff\n}\n\nfunction rptPutU32(arr, at, value) {\n arr[at] = value & 0xff\n arr[at + 1] = (value >>> 8) & 0xff\n arr[at + 2] = (value >>> 16) & 0xff\n arr[at + 3] = (value >>> 24) & 0xff\n}\n\n// flag bit 11 = 文件名为 UTF-8。本模块的文件名全是 ASCII,设上无副作用,\n// 但万一以后加了中文部件名,这里不用再改。\nconst RPT_ZIP_FLAG = 0x0800\n\nfunction rptZipLocalHeader(nameBytes, crc, size) {\n const h = new Uint8Array(30 + nameBytes.length)\n rptPutU32(h, 0, 0x04034b50)\n rptPutU16(h, 4, 20) // version needed\n rptPutU16(h, 6, RPT_ZIP_FLAG)\n rptPutU16(h, 8, 0) // method 0 = stored\n rptPutU16(h, 10, RPT_DOS_TIME)\n rptPutU16(h, 12, RPT_DOS_DATE)\n rptPutU32(h, 14, crc)\n rptPutU32(h, 18, size) // compressed size == uncompressed size(stored)\n rptPutU32(h, 22, size)\n rptPutU16(h, 26, nameBytes.length)\n rptPutU16(h, 28, 0) // extra field length\n h.set(nameBytes, 30)\n return h\n}\n\nfunction rptZipCentralHeader(nameBytes, crc, size, offset) {\n const h = new Uint8Array(46 + nameBytes.length)\n rptPutU32(h, 0, 0x02014b50)\n rptPutU16(h, 4, 20) // version made by(MS-DOS / 2.0)\n rptPutU16(h, 6, 20) // version needed\n rptPutU16(h, 8, RPT_ZIP_FLAG)\n rptPutU16(h, 10, 0)\n rptPutU16(h, 12, RPT_DOS_TIME)\n rptPutU16(h, 14, RPT_DOS_DATE)\n rptPutU32(h, 16, crc)\n rptPutU32(h, 20, size)\n rptPutU32(h, 24, size)\n rptPutU16(h, 28, nameBytes.length)\n rptPutU16(h, 30, 0) // extra\n rptPutU16(h, 32, 0) // comment\n rptPutU16(h, 34, 0) // disk number start\n rptPutU16(h, 36, 0) // internal attrs\n rptPutU32(h, 38, 0) // external attrs\n rptPutU32(h, 42, offset) // 本条目 local header 的偏移\n h.set(nameBytes, 46)\n return h\n}\n\nfunction rptZipEocd(count, cdSize, cdOffset) {\n const h = new Uint8Array(22)\n rptPutU32(h, 0, 0x06054b50)\n rptPutU16(h, 4, 0) // 本磁盘号\n rptPutU16(h, 6, 0) // 中央目录起始磁盘号\n rptPutU16(h, 8, count) // 本磁盘条目数\n rptPutU16(h, 10, count) // 总条目数\n rptPutU32(h, 12, cdSize)\n rptPutU32(h, 16, cdOffset)\n rptPutU16(h, 20, 0) // 注释长度\n return h\n}\n\n// entries: [{ name, data: Uint8Array }] → 完整 ZIP 字节流。\n// 故意用 stored:报告产物只有几十 KB,压缩省不下多少,却要多一条 inflate 实现与\n// 一大类「解压出来不对」的失败面。真需要压缩时应交给调用方而不是这里。\nfunction rptZipBuild(entries) {\n const local = []\n const central = []\n let offset = 0\n for (const e of entries) {\n const nameBytes = rptUtf8Encode(e.name)\n const data = e.data\n const crc = rptCrc32(data)\n const head = rptZipLocalHeader(nameBytes, crc, data.length)\n local.push(head, data)\n central.push(rptZipCentralHeader(nameBytes, crc, data.length, offset))\n offset += head.length + data.length\n }\n let cdSize = 0\n for (const c of central) cdSize += c.length\n const chunks = local.concat(central, [rptZipEocd(entries.length, cdSize, offset)])\n let total = 0\n for (const c of chunks) total += c.length\n const out = new Uint8Array(total)\n let pos = 0\n for (const c of chunks) { out.set(c, pos); pos += c.length }\n return out\n}\n" });
|
|
83
|
+
|
|
84
|
+
// 插件自己的 JSON-RPC 端点。
|
|
85
|
+
// 客户端 bundle 用 fetch 调它(静态模块可用 fetch;动态半边才被屏蔽)。
|
|
86
|
+
// 全部挂在 /dsh-redteam-report 命名空间下,避免与其它插件的路由相撞。
|
|
87
|
+
const RPC_PATH = '/dsh-redteam-report/rpc'
|
|
88
|
+
|
|
89
|
+
// 把 20 个动态 RPC 句柄经宿主 HTTP 路由暴露给客户端半边。
|
|
90
|
+
// 客户端是普通模块,可以直接 fetch(动态半边才有 fetch 屏蔽)。
|
|
91
|
+
ctx.effect(() => ctx.webServer.register({
|
|
92
|
+
kind: 'exact',
|
|
93
|
+
path: RPC_PATH,
|
|
94
|
+
handler: async (req, res) => {
|
|
95
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
96
|
+
let body = ''
|
|
97
|
+
try { for await (const chunk of req) body += chunk } catch (e) {}
|
|
98
|
+
let payload = null
|
|
99
|
+
try { payload = JSON.parse(body || '{}') } catch (e) {}
|
|
100
|
+
const method = payload && typeof payload.method === 'string' ? payload.method : ''
|
|
101
|
+
const fn = handlers[method]
|
|
102
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
103
|
+
if (!fn) { res.statusCode = 404; res.end(JSON.stringify({ error: 'unknown method: ' + method })); return }
|
|
104
|
+
try {
|
|
105
|
+
const result = await fn(payload.args === undefined ? null : payload.args)
|
|
106
|
+
res.statusCode = 200
|
|
107
|
+
res.end(JSON.stringify({ ok: true, result: result === undefined ? null : result }))
|
|
108
|
+
} catch (e) {
|
|
109
|
+
res.statusCode = 500
|
|
110
|
+
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }))
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}), 'rtasset: host rpc route')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const name = 'redteam-report'
|
|
117
|
+
// 三个工具注册进宿主 tools 注册表;这里声明本半边硬依赖的服务。
|
|
118
|
+
export const inject = ['fs', 'shell', 'timer', 'tools', 'workspaceRegistry', 'webServer']
|
|
119
|
+
export { applyHost as apply }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// 常驻(静态)客户端半边 —— 浏览器 bundle 形态(由 tools/build-lib.mjs 生成)。
|
|
2
|
+
//
|
|
3
|
+
// client-modules 是 CJS 懒执行模型:bundle 只【注册】工厂,副作用留在闭包内,
|
|
4
|
+
// 首次 require 时物化。因此这里用 window.__ModuleLoader__.load({id, factory}) 注册。
|
|
5
|
+
//
|
|
6
|
+
// 包装刻意保持极薄(只做作用域与导出),全部改造集中在主体自己的 applyClient 里,
|
|
7
|
+
// 见 lib/parts/client.shim.js。
|
|
8
|
+
window.__ModuleLoader__.load({
|
|
9
|
+
id: '__PKG_NAME__',
|
|
10
|
+
factory: (require) => {
|
|
11
|
+
let React = require('react');
|
|
12
|
+
var module = { exports: {} };
|
|
13
|
+
var exports = module.exports;
|
|
14
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
15
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
function applyClient(ctx) {
|
|
2
|
+
// ── 静态形态垫片(动态半边的闭包符号在静态包里不存在)──
|
|
3
|
+
//
|
|
4
|
+
// 1) host.call:转到宿主 HTTP 路由(见 lib/host.js 的 __ROUTE_BASE__/rpc)。
|
|
5
|
+
// 选 HTTP 而非 ctx.remote:Remote 需 typert 代码生成(zod schema + 生成绑定),
|
|
6
|
+
// 而本插件有 20 个无类型 JSON 句柄,为此引入整套生成链不划算;
|
|
7
|
+
// 且 fetch 只被【动态】半边屏蔽,静态模块可直接用。
|
|
8
|
+
// 2) styles.insert:动态 runner 把它作为闭包参数注入,静态 bundle 里没有,
|
|
9
|
+
// 故自行插入 <style> 元素(浏览器全局可用),并登记到 fiber 便于卸载清理。
|
|
10
|
+
// 路径必须由生成器按包名填充(__ROUTE_BASE__)。这里曾经写死成
|
|
11
|
+
// `/dsh-redteam-asset-graph/rpc` —— 那是从资产图谱早期版本复制骨架时带过来的缺陷:
|
|
12
|
+
// 本插件的面板会去打资产图谱的路由,请求全 404,两者同时安装还会读到对方的数据
|
|
13
|
+
// (资产图谱 README 记的那次事故是同一个根因)。测试里钉住了实际请求的 url。
|
|
14
|
+
const RPC_PATH = '__ROUTE_BASE__/rpc'
|
|
15
|
+
const host = {
|
|
16
|
+
call(method, args) {
|
|
17
|
+
return fetch(RPC_PATH, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'content-type': 'application/json' },
|
|
20
|
+
body: JSON.stringify({ method: method, args: args === undefined ? null : args }),
|
|
21
|
+
}).then(function (res) {
|
|
22
|
+
return res.json().catch(function () { return null }).then(function (payload) {
|
|
23
|
+
if (!res.ok || !payload || payload.ok !== true) {
|
|
24
|
+
const detail = (payload && payload.error) || ('HTTP ' + res.status)
|
|
25
|
+
throw new Error('__PLUGIN_NAME__ rpc ' + method + ' 失败:' + detail)
|
|
26
|
+
}
|
|
27
|
+
return payload.result
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STYLE_ID = '__PLUGIN_NAME__-styles'
|
|
34
|
+
const styles = {
|
|
35
|
+
insert(css) {
|
|
36
|
+
if (typeof document === 'undefined') return function () {}
|
|
37
|
+
let el = document.getElementById(STYLE_ID)
|
|
38
|
+
if (!el) { el = document.createElement('style'); el.id = STYLE_ID; document.head.appendChild(el) }
|
|
39
|
+
el.textContent += String(css) + '\n'
|
|
40
|
+
const dispose = function () { if (el && el.parentNode) el.parentNode.removeChild(el) }
|
|
41
|
+
try { ctx.effect(function () { return dispose }, '__PLUGIN_NAME__: styles') } catch (e) { return dispose }
|
|
42
|
+
return dispose
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 常驻(静态)Host 半边。
|
|
2
|
+
//
|
|
3
|
+
// 正式入口调用 workspace-install:旧 src/host.js 作为源码数据交给工作区运行器,
|
|
4
|
+
// 每个工作区独立实例化,避免共享全局报告库。这里提供工具与 HTTP 的薄垫片:
|
|
5
|
+
//
|
|
6
|
+
// defineTool / registerTool -> @deepseek-ai/dsh-tools 的 defineTool + ctx.tools.register
|
|
7
|
+
// handle -> 收进 handlers 表,供宿主 HTTP 路由转发(见 rpcRoute)
|
|
8
|
+
//
|
|
9
|
+
// 这样做的理由:机械改写上千行主体逻辑的风险远高于加一层适配,
|
|
10
|
+
// 而且适配层把「动态 ↔ 静态」的差异集中在一个地方,便于日后核对。
|
|
11
|
+
//
|
|
12
|
+
// ── defineTool 的入参形态差异(实测踩坑,务必保留转换)────────────────────────
|
|
13
|
+
// 动态半边的 harness.defineTool 由 dsh-cordis-host-runner 的 guard 提供,它按
|
|
14
|
+
// 「JSON Schema」接受 parameters({ type:'object', properties, required })。
|
|
15
|
+
// 静态包的 defineTool 来自 @deepseek-ai/dsh-tools,它要的是 ParameterSchemaSpec:
|
|
16
|
+
// 一个**扁平的属性表**,必填写成每个属性上的 required: true,且根对象没有 type 字段。
|
|
17
|
+
// 直接把 JSON Schema 喂给静态 defineTool 会抛
|
|
18
|
+
// JsonSchemaError: unsupported JSON schema: parameters.type must be a value schema object
|
|
19
|
+
// —— 工具会在 apply 时全部注册失败。
|
|
20
|
+
// 所以这里做一次转换,src/ 保持动态形态不变。
|
|
21
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
|
+
import { rptInstallWorkspaceReports } from '../src/workspace-install.js'
|
|
23
|
+
|
|
24
|
+
// JSON Schema 属性节点 -> ParameterSchemaSpec 属性节点。只带上工具真的用到的键,
|
|
25
|
+
// 不搬运 pattern / format 之类静态编译器不接受的约束。
|
|
26
|
+
function toPropertySpec(node) {
|
|
27
|
+
if (!node || typeof node !== 'object') return { type: 'string' }
|
|
28
|
+
const annotations = {}
|
|
29
|
+
if (typeof node.description === 'string') annotations.description = node.description
|
|
30
|
+
if (node.default !== undefined) annotations.default = node.default
|
|
31
|
+
if (Array.isArray(node.examples)) annotations.examples = node.examples
|
|
32
|
+
const t = node.type
|
|
33
|
+
if (t === 'array') {
|
|
34
|
+
const spec = { type: 'array', items: toPropertySpec(node.items), ...annotations }
|
|
35
|
+
if (typeof node.minItems === 'number') spec.minItems = node.minItems
|
|
36
|
+
if (typeof node.maxItems === 'number') spec.maxItems = node.maxItems
|
|
37
|
+
return spec
|
|
38
|
+
}
|
|
39
|
+
if (t === 'object') {
|
|
40
|
+
return { type: 'object', additionalProperties: node.additionalProperties === false ? false : true, properties: toPropertyMap(node.properties), ...annotations }
|
|
41
|
+
}
|
|
42
|
+
const spec = { type: t || 'string', ...annotations }
|
|
43
|
+
if (Array.isArray(node.enum)) spec.enum = node.enum.slice()
|
|
44
|
+
if (node.const !== undefined) spec.const = node.const
|
|
45
|
+
return spec
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toPropertyMap(props) {
|
|
49
|
+
const out = {}
|
|
50
|
+
if (props && typeof props === 'object') for (const key of Object.keys(props)) out[key] = toPropertySpec(props[key])
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 接受动态形态的 parameters;已是扁平属性表时原样返回(幂等,便于两种写法共存)。
|
|
55
|
+
function toParameterSpec(parameters, required) {
|
|
56
|
+
if (!parameters || typeof parameters !== 'object') return { type: 'object', properties: {}, additionalProperties: false }
|
|
57
|
+
let props = parameters.properties
|
|
58
|
+
if (props === undefined && parameters.type !== 'object') props = parameters
|
|
59
|
+
const map = toPropertyMap(props)
|
|
60
|
+
const req = Array.isArray(required) ? required : (Array.isArray(parameters.required) ? parameters.required : [])
|
|
61
|
+
for (const name of req) if (map[name] && typeof map[name] === 'object') map[name].required = true
|
|
62
|
+
return map
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 工具定义里除了 parameters 之外都与静态 defineTool 兼容,只替换这一个字段。
|
|
66
|
+
function toStaticToolDefinition(definition) {
|
|
67
|
+
const rest = {}
|
|
68
|
+
for (const key of Object.keys(definition)) if (key !== 'parameters') rest[key] = definition[key]
|
|
69
|
+
rest.parameters = toParameterSpec(definition.parameters, definition.required)
|
|
70
|
+
return rest
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function applyHost(ctx) {
|
|
74
|
+
const handlers = Object.create(null)
|
|
75
|
+
const harness = {
|
|
76
|
+
defineTool(definition) { return defineTool(toStaticToolDefinition(definition)) },
|
|
77
|
+
registerTool(c, tool) { return c.tools.register(tool) },
|
|
78
|
+
handle(method, handler) { handlers[method] = handler; return () => { delete handlers[method] } },
|
|
79
|
+
}
|
|
80
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
|
|
2
|
+
// 插件自己的 JSON-RPC 端点。
|
|
3
|
+
// 客户端 bundle 用 fetch 调它(静态模块可用 fetch;动态半边才被屏蔽)。
|
|
4
|
+
// 全部挂在 __ROUTE_BASE__ 命名空间下,避免与其它插件的路由相撞。
|
|
5
|
+
const RPC_PATH = '__ROUTE_BASE__/rpc'
|
|
6
|
+
|
|
7
|
+
// 把 20 个动态 RPC 句柄经宿主 HTTP 路由暴露给客户端半边。
|
|
8
|
+
// 客户端是普通模块,可以直接 fetch(动态半边才有 fetch 屏蔽)。
|
|
9
|
+
ctx.effect(() => ctx.webServer.register({
|
|
10
|
+
kind: 'exact',
|
|
11
|
+
path: RPC_PATH,
|
|
12
|
+
handler: async (req, res) => {
|
|
13
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
14
|
+
let body = ''
|
|
15
|
+
try { for await (const chunk of req) body += chunk } catch (e) {}
|
|
16
|
+
let payload = null
|
|
17
|
+
try { payload = JSON.parse(body || '{}') } catch (e) {}
|
|
18
|
+
const method = payload && typeof payload.method === 'string' ? payload.method : ''
|
|
19
|
+
const fn = handlers[method]
|
|
20
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
21
|
+
if (!fn) { res.statusCode = 404; res.end(JSON.stringify({ error: 'unknown method: ' + method })); return }
|
|
22
|
+
try {
|
|
23
|
+
const result = await fn(payload.args === undefined ? null : payload.args)
|
|
24
|
+
res.statusCode = 200
|
|
25
|
+
res.end(JSON.stringify({ ok: true, result: result === undefined ? null : result }))
|
|
26
|
+
} catch (e) {
|
|
27
|
+
res.statusCode = 500
|
|
28
|
+
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }))
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}), 'rtasset: host rpc route')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const name = '__PLUGIN_NAME__'
|
|
35
|
+
// 三个工具注册进宿主 tools 注册表;这里声明本半边硬依赖的服务。
|
|
36
|
+
export const inject = ['fs', 'shell', 'timer', 'tools', 'workspaceRegistry', 'webServer']
|
|
37
|
+
export { applyHost as apply }
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-redteam-report",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH 红队报告:按工作区隔离,切换时自动采集文件与会话并生成可导出的报告",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "fasthei",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Fasthei/DSHairedPlugin.git",
|
|
10
|
+
"directory": "packages/report"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"dsh",
|
|
14
|
+
"cordis",
|
|
15
|
+
"plugin",
|
|
16
|
+
"redteam",
|
|
17
|
+
"report"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "lib/host.js",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"default": "./lib/host.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": {
|
|
26
|
+
"default": "./lib/client.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"lib",
|
|
32
|
+
"src",
|
|
33
|
+
"tools",
|
|
34
|
+
"cordis.patch.yml",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md",
|
|
37
|
+
"WORKSPACE-REPORTS.md"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
},
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"platform": "web"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
53
|
+
"dsh-redteam-memory": "^0.4.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build:lib": "node tools/build-lib.mjs",
|
|
57
|
+
"check:lib": "node tools/build-lib.mjs --check",
|
|
58
|
+
"prepack": "node tools/build-lib.mjs --check",
|
|
59
|
+
"publish:gh": "node tools/prepare-gh-packages.mjs",
|
|
60
|
+
"test": "node test/docx-smoke.mjs && node test/report-flow.mjs && node test/render-smoke.mjs && node test/workspace-evidence.mjs && node test/workspace-runtime.mjs && node test/published-smoke.mjs",
|
|
61
|
+
"test:docx": "node test/docx-smoke.mjs",
|
|
62
|
+
"test:flow": "node test/report-flow.mjs",
|
|
63
|
+
"test:render": "node test/render-smoke.mjs"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": ">=20"
|
|
67
|
+
}
|
|
68
|
+
}
|