这是《DeepSeek Harness 权威指南 》系列第 15 篇,二开线(B 线)收官。源码基线 deepseek-harness @
47f9438;项目仓库 rexleimo/rex-dhs-core (公开,MIT)。B1-B6 每一篇都在讲一个 seam。这篇把 B 线全部能力串成一个有落地场景的真实项目:需求 → 设计 → 架构 → 实现 → 真实运行 → 截图 → 分发。所有环节都有真实证据;唯一没做完的是"真实问答回合"——它需要你的 API key,文章末尾给了补跑步骤。
一、需求
一句话:让 agent 能在一个本地 markdown 文档库(只读)里搜索并阅读内容,从而基于真实资料回答知识库问题。
用户故事:
- 作为团队成员,我提问"我们平台的限流策略是什么",agent 应搜索知识库并引用实际文档段落回答;
- 作为知识库维护者,我要求 agent 只能读配置的文档根目录,绝不能读到根目录之外的任意文件;
- 作为安全负责人,我要求每一次 kb 工具调用都经过可审计的策略层,并能在 UI 上看到结构化卡片。
非目标(明确不做):向量/嵌入索引、文件写入、web 抓取、外部知识源、企业级权限系统。
二、设计:复用 B 线,只写域逻辑
| B 线能力 | 复用点 | 本项目落点 |
|---|---|---|
| B1 工具注册 | defineTool + output.schema/render | kb_query、kb_read |
| B3 权限门 | tools/pre-execute + ctx.tools.guard() | KbPathGate 路径白名单 + owner 单调拒绝 |
| B4 结果投影 | presentationMeta / presentResult | kb_read 的 read 卡片(行号/总行数) |
| B5 事件流 | session/event 订阅 | 调用自然进入 turn 日志(tool/call ↔ tool/result) |
| B6 打包 | named-export 契约 + manifest | 独立 package @rex-local/dsh-kb-qa |
关键架构决策:域逻辑与装配分离。path-policy / search-core / read-core 是纯函数(无 Context、无 I/O),可以零依赖单测;index.ts 只做接线。这来自官方 fs 三组件拆分的"能力 seam"思想(docs/cookbook/adding-a-package.zh.md §3)。

图:装配层注册工具并接线门与 guard;三个纯函数模块可独立单测;I/O 只读、路径收窄、输出有界。
三、实现
1. 路径策略(纯函数,src/path-policy.ts)
export function resolveWithinRoot(root: string, candidate: string): PathVerdict {
if (typeof candidate !== 'string' || candidate.length === 0) {
return { ok: false, reason: 'empty path' }
}
if (candidate.includes('\0')) {
return { ok: false, reason: 'path contains NUL byte' }
}
const abs = isAbsolute(candidate) ? candidate : resolve(root, candidate)
const rel = relative(root, abs)
if (rel === '') return { ok: false, reason: 'path resolves to the root itself' }
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
return { ok: false, reason: `path escapes the knowledge base root: ${candidate}` }
}
return { ok: true, abs }
}
拒绝的每一类都在单元检查里断言过:.. 逃逸、根外绝对路径、空路径、NUL、指向 root 本身。kb_read 的 execute 里还做了第二道防线——realpath 复查,防符号链接逃逸:
const [rootReal, targetReal] = await Promise.all([
fsp.realpath(config.root),
fsp.realpath(abs).catch(() => null),
])
if (targetReal === null) throw kbError(`file not found: ${verdict.rel}`, 'KB_FILE_NOT_FOUND')
if (!targetReal.startsWith(rootReal + sep)) {
throw kbError(`path escapes the knowledge base root: ${verdict.rel}`, 'KB_PATH_ESCAPE')
}
2. 有界扫描(纯函数,src/search-core.ts)
export function searchMarkdown(query: string, files: SearchInput[], options: SearchOptions): SearchOutcome {
let re: RegExp
try {
re = new RegExp(query, 'iu')
} catch (error) {
throw new KbSearchError(`invalid query pattern: ${query}`, 'KB_INVALID_QUERY')
}
const matches: MatchLine[] = []
let truncated = false
for (const file of files) {
if (matches.length >= options.maxResults) { truncated = true; break }
let perFile = 0
const lines = file.content.split(LINE_SPLIT)
for (let i = 0; i < lines.length; i++) {
if (perFile >= options.maxPerFile) { truncated = true; break }
const line = lines[i] ?? ''
if (Buffer.byteLength(line, 'utf8') > options.maxLineBytes) continue
re.lastIndex = 0
if (re.test(line)) {
matches.push({ file: file.file, lineNumber: i + 1, line })
perFile++
}
}
}
if (matches.length >= options.maxResults) truncated = true
return { matches: matches.slice(0, options.maxResults), totalFiles: files.length, truncated }
}
有界性三重:总结果上限、单文件上限、行长上限(超长行跳过并置截断标志)。canonical value 永远是 { matches, totalFiles, truncated }——模型拿到的每个字段都是确定性的。
3. 装配(src/index.ts):两个工具 + 门 + guard
(节选:// ... 行为作者省略,其余逐字;完整文件见 kb-qa-plugin/src/index.ts
)
export function apply(ctx: Context, config: Config): void {
// ---- owner-level final guard (B3 guard seam): monotonic deny only ----
ctx.tools.guard((exec) => {
if (exec.name !== 'kb_query' && exec.name !== 'kb_read') return undefined
if (config.deniedToolNames.includes(exec.name)) {
return `kb-qa owner guard denies "${exec.name}"`
}
return undefined
})
// ---- pre-execute gate: any kb_* path argument must stay inside root ----
ctx.on('tools/pre-execute', async (exec, next): Promise<{ kind: 'deny'; reason: string } | undefined> => {
if (exec.name === 'kb_query') {
const prefix = (exec.arguments as { pathPrefix?: unknown }).pathPrefix
if (prefix !== undefined) {
const verdict = toRootRelative(config.root, String(prefix))
if (!verdict.ok) return { kind: 'deny', reason: `kb_query pathPrefix rejected: ${verdict.reason}` }
}
}
if (exec.name === 'kb_read') {
const path = (exec.arguments as { path?: unknown }).path
if (typeof path !== 'string') return { kind: 'deny', reason: 'kb_read requires a string path' }
const verdict = toRootRelative(config.root, path)
if (!verdict.ok) return { kind: 'deny', reason: `kb_read path rejected: ${verdict.reason}` }
if (config.askOnRead) return { kind: 'ask', reason: 'kb_read requires approval in this deployment' }
}
return next()
})
// ... ctx.tools.register(defineTool({ name: 'kb_query', ... }))
// ... ctx.tools.register(defineTool({ name: 'kb_read', ... presentResult: read card }))
}
开发中踩到的真实坑(写进文章以儆效尤):ctx.tools.guard() 的签名是单个 ToolGuard 函数((exec) => string | undefined),不是 (name, guard)——第一次按错误签名写,运行时报 guard is not a function。B3 文章只展示了用法,这次把签名也钉死。
四、验证:三层证据
1. 单元层(零依赖)
node --import tsx tests/unit-checks.ts
PASS path-policy: inside / absolute-inside / .. escape / absolute escape / empty / root itself
PASS search-core: hits / per-call cap / long-line skip / invalid pattern code
PASS read-core: window / clamp / cap / beyond-end
KB_QA_UNIT_CHECKS=ALL_PASS
(read-core 的语义修正也如实记录:末尾换行不是空行;请求区间超出文件必须标 truncated 而不是静默。)
2. 集成层:真实 ctx 管线六 case
node --import tsx demo/integration-demo.ts
真实输出(与 demo/integration-demo-output.txt 逐字一致):
=== A: kb_query success ===
{"isError":false,"value":{"matches":[{"file":"limits.md","lineNumber":3,"line":"本平台对 DeepSeek API 请求实施**速率限制(rate limit)**:"}],"totalFiles":2,"truncated":false}}
=== B: kb_read success ===
{"isError":false,"value":{"path":"limits.md","lines":["# 平台限流策略","","本平台对 DeepSeek API 请求实施**速率限制(rate limit)**:","","- 免费额度:每分钟 10 次请求(RPM);"],"startLine":1,"endLine":5,"totalLines":20,"truncated":false}}
=== C: kb_read path escape ===
{"isError":true,"error":{"message":"kb_read path rejected: path escapes the knowledge base root: ../secret.md"}}
=== D: kb_read outside-root absolute ===
{"isError":true,"error":{"message":"kb_read path rejected: path escapes the knowledge base root: C:/Windows/system32/drivers/etc/hosts"}}
=== F: kb_query invalid regex ===
{"isError":true,"error":{"message":"invalid query pattern: ("}}
=== E: kb_read askOnRead without approval service ===
{"isError":true,"error":{"message":"kb_read requires approval in this deployment"}}
A/B 成功路径走完整 registry 管线;C/D 被 pre-execute 门在 body 前拒绝(B3 的 deny 语义);F 的非法正则变成规范错误(B4 的错误投影);E 证明 askOnRead 在没有 approval service 时 fail-closed(B3 的 serviceAsk 分支 ①)。
3. 真实运行:dsh web profile 装载(本项目最大突破)
B3 以来"本机无法真实启动 pnpm dsh web"的障碍,在 B7 用 npx [email protected] 绕过错坏的 Corepack shim 后解除。真实启动:
npx --yes [email protected] dsh web --patch E:/coding/rex-dhs-core/kb-qa-plugin/cordis.yml
证据 1:dump-config 显示 patch 行完整组合进 profile(demo/dump-config-output.txt 503 行,节选):
# == E:\coding\rex-dhs-core\kb-qa-plugin\cordis.yml
- id: kb-qa
name: file:///E:/coding/rex-dhs-core/kb-qa-plugin/src/index.ts
config:
root: E:/coding/rex-dhs-core/kb-qa-plugin/fixtures/docs
maxQueryLength: 200
maxMatches: 10
maxLineBytes: 500
maxLines: 60
maxFiles: 200
askOnRead: false
deniedToolNames: []
证据 2:web UI 插件列表——搜索 kb-qa 命中唯一条目(作者本机截图,见下方):

file:///E:/coding/rex-dhs-core/kb-qa-plugin/src/index.ts, 已挂载, 已启用
id: include:kb-qa
配置状态: 已启用 Cordis 状态: 已挂载
证据 3:真实问答回合——需要 DEEPSEEK_API_KEY(本机未配置,如实标注)。补跑步骤:
# 在 deepseek-harness 根目录
npx --yes [email protected] dsh web --patch E:/coding/rex-dhs-core/kb-qa-plugin/cordis.yml
# 浏览器打开 http://127.0.0.1:3080,在 API Key 弹窗粘贴你的 key
# 新建会话提问:"我们的限流策略是什么?"
# 期望:agent 调用 kb_query(rate\s+limit)→ kb_read limits.md → 引用段落回答
五、事件流:一次 kb_query 从头到尾

图:门在 body 前决定去留;guard 施加最终单调拒绝;成功结果走 B4 投影链进入日志、transcript 与 UI。
对照 B5 的 turn 结构:模型请求 → assistant/message: tool-call → tool/call(arguments 原文)→ registry 流水线(门 → guard → body)→ tool/result(message + meta,无 value)→ 同 turn step 2 回填模型。kb-qa 的贡献只在这条链的门与 body:路径策略决定"能不能读",有界扫描决定"读多少"。
六、分发
项目已推送到公开仓库 rexleimo/rex-dhs-core(commit 1bc0cbe,紧随 B0-B6 demo 集 6529fdd):
rex-dhs-core/
├── README.md # 仓库总览(目录对照 / 运行前置 / 边界声明)
├── docs/b7-kb-qa-plan.md # 本项目的设计文档
└── kb-qa-plugin/
├── package.json # B6 不变式(workspace:^ 依赖镜像)
├── src/{index,path-policy,search-core,read-core}.ts
├── tests/unit-checks.ts # 零依赖单元检查
├── fixtures/docs/ # 示例知识库(limits.md / deploy.md)
├── demo/integration-demo.ts + outputs
└── cordis.yml # dsh web --patch 入口
给读者的装载路径(三选一):
# ① 临时叠加(作者本机验证的方式)
npx --yes [email protected] dsh web --patch <你的路径>/kb-qa-plugin/cordis.yml
# ② 用户 profile 持久化:把 cordis.yml 的内容合并进 $DSH_HOME/cordis.patch.yml
# ③ 正式包:按 B6 规范发布 @rex-local/dsh-kb-qa 后,在 cordis.yml 里用包名装载
七、常见错误写法
| 过强或错误的说法 | 更准确的表述 |
|---|---|
| “kb-qa 是安全的权限系统。” | 它是 per-call 策略示例:只读 + 路径收窄 + 有界,不是 IAM。 |
| “kb_query 是全文检索。” | 它是逐行正则扫描,无索引;大知识库性能未优化(有界是刻意选择)。 |
| “demo 证明了真实问答。” | 集成 demo 证明工具管线;真实问答回合需 API key,已给出补跑步骤。 |
| “guard(name, fn) 是正确签名。” | 实测签名是 ctx.tools.guard(fn),按 exec.name 自行分流。 |
| “read-core 的截断标志只在超长时出现。” | 请求区间超出文件末尾也会标 truncated(语义修正已测)。 |
八、总结:B 线至此闭环
B1 教会你注册工具,B2 接模型,B3 上策略,B4 投影结果,B5 看事件流,B6 打包——B7 把它们合成一个能真实装载进产品 profile 的插件。回顾整条线,二开 dsh 插件的通用配方是:
- 域逻辑写成纯函数(可单测、可推理);
- 能力收窄是卖点(只读、有界、root 白名单),不是功能缺失;
- 策略走门与 guard,不要塞进工具 body;
- canonical value 一次成型,投影交给 render/presentationMeta/presentResult;
- 真实装载用
npx pnpm dsh web --patch,截图与 dump-config 就是证据。
下一篇预告:系列将进入收尾——全量审核、分批发布与 IndexNow 上线(见栏目计划阶段 6)。B 线的新案例(MCP 集成、headless agent、Python SDK 等计划中的主题)会以同样标准陆续补充。
一个端到端项目最难的不是写代码,而是每一层都有证据:单元检查证明纯函数、集成 demo 证明管线、dump-config 与 UI 截图证明真实装载。没有 API key 就诚实标注缺一环,并给出补跑步骤——这就是这个系列一贯的标准。
