Răsfoiți Sursa

fix(v7): P3 中危合批——YAML key 校验/契约锁自愈/git 锁阈值/books.jsonl 双侧(F8-F11)

- F8 YAML key 注入:serializeYAML 循环头 assertSafeYamlKey 白名单——key 含
  换行/冒号/井号/控制字符/首尾空白/引号引导/空一律 throw 人话并指认字段名,
  与嵌套映射检测同姿势(决策 62 拒绝不改写)。round-trip 回归绿。
- F9 契约锁陈旧自愈(按 07-23 后置债务清单口径,非静默回收):
  readStaleContractLock 双判——pid 存活探测优先(ESRCH=陈旧;EPERM/不可达
  保守视为活锁),无 pid 回退 30 分 mtime 兜底;next 序 0 检出后入 failures
  (action=delete + 人话指引)走既有修复确认流,作者确认才删;persistRepair
  新增 delete 行动,REPAIR_DELETE_ALLOWED 冻结白名单只放行契约锁路径
 (失败清单 ∩ 白名单双闸);EEXIST 文案补锁路径与序 0 指引。活锁拒绝零回退。
  CONTRACT_UPDATE_LOCK 统一为正斜杠字面量(DTO 展示与白名单比对同口径)。
- F10 git index.lock:阈值 3s→60s(杀软/索引器锁盘误删场景)+ 删除前二次
  stat 防 TOCTOU;index.lock 不含 pid,进程存活判定不可行,收窄为年龄双查。
- F11 books.jsonl 写读双侧:isValidBookEntry 单源(目录=isSafeFileStem
  复用 P0);读侧形状非法行入 corrupt 走既有自愈回写;写侧 registerBook
  拒绝非法目录(原实现仅查非空,坏目录可让 git clean/fs.rm 落到工作目录外)。
- F12 路由 #135 同构核查:三场景实测 + resolveBookKnowledge 源码逐行审——
  v7 路由为声明表归一、无 fallback/默认/继承分支,零改动收口(证据留档
  任务 research/route-isomorphism-check.md)。
- spec:story-repo 0.21 决策 64(四条口径合批)。

验证(分工 kimi 实施 / claude 检查):Windows 全量 733/733 绿 + drift 绿;
WSL 平台相关五文件 90/90 绿 + F9 delete 三例绿(process.kill ESRCH/EPERM
POSIX 语义重点核过);F9 序 0 走查=非静默/活锁不误删/白名单未推广三项过。

Refs 父任务 07-29-v7-review-findings F8-F11
lingfengQAQ 1 lună în urmă
părinte
comite
c4559ce18a

+ 10 - 2
docs/architecture/story-repo-spec-2026-06-10.md

@@ -1,6 +1,8 @@
-# Story Repo 格式规格(v7 现行 0.20)
+# Story Repo 格式规格(v7 现行 0.21)
 
-> 状态:0.20(2026-07-30 DTO 降级显式标记决策,决策 63 见 §14):有损降级在送达 AI 的 DTO 上显式可见——AI 必须能区分「没有数据」与「读取失败后的残缺数据」。
+> 状态:0.21(2026-07-31 P3 中危合批决策,决策 64 见 §14):YAML key 白名单;git 锁 60s+二次 stat;契约锁陈旧自愈走序0 确认+删除白名单;books.jsonl 写读双侧行形状校验。
+>
+> 0.20(2026-07-30 DTO 降级显式标记决策,决策 63 见 §14):有损降级在送达 AI 的 DTO 上显式可见——AI 必须能区分「没有数据」与「读取失败后的残缺数据」。
 >
 > 0.19(2026-07-29 P0 路径穿越修复决策,决策 62 见 §14):AI 可控字段进文件路径一律白名单校验(信息差 id/时间线卷号/伏笔条目 id),写入器自卫 + 共享 payload 校验层 + writeAtomicBatch 总闸边界,章号/卷号入参统一正整数口径。
 >
@@ -696,6 +698,12 @@ v6 的 8 个命令全部内化为以上状态。作者只需要一个入口和"
 
 ## 14. 决策记录
 
+### 0.20 → 0.21(依据:2026-07-31 P3 中危合批,任务 07-29-p3-medium-batch)
+
+| # | 变更 | 落点 | 来源 |
+|---|------|------|------|
+| 64 | **中危防护合批**:① YAML 防呆方言 key 白名单——key 含控制字符(含换行)/冒号/井号/首尾空白/引号引导/为空一律 throw 人话并指认字段名(与嵌套检测同款姿势,校验拒绝而非静默改写;AI 可控 updates 键曾可注入 front matter 结构);② git `index.lock` 陈旧阈值 3s→60s + 删除前二次 stat 年龄确认(杀软/索引器瞬时持锁超 3s 曾被误删,git 锁文件不含 pid 无法判活锁,退分钟级+防 TOCTOU);③ 契约互斥锁陈旧自愈——`readStaleContractLock` 双判(pid 存活探测优先,EPERM/探测失败保守按活锁;无 pid 信息回退 30 分钟 mtime 兜底)+ 检测进序0 修复确认流 + `persistRepair` 新增 `action: "delete"`(**删除面白名单钳死只放行该锁路径**,不开任意删除);非静默自动回收,活锁保持拒绝;④ `books.jsonl` 写读双侧校验——`registerBook` 与 `readBooksRegistry` 共用行形状判定(书名非空字符串、目录为单层安全文件名干即 `isSafeFileStem` 复用 P0 单源),形状非法行计入 corrupt 走既有自愈回写通道,目录穿越/绝对路径/分隔符进不了书单 | §3 原子性、§10 序0、§11 books.jsonl | 任务 `.trellis/tasks/07-29-p3-medium-batch/`(四项攻击/故障样例测试 + F12 路由核查 `research/route-isomorphism-check.md`:v7 无 #135 同构 fallback,零改动收口) |
+
 ### 0.19 → 0.20(依据:2026-07-30 DTO 降级显式标记,任务 07-29-p1-dto-degraded-flag)
 
 | # | 变更 | 落点 | 来源 |

+ 33 - 4
v7/src/session/index.js

@@ -1,6 +1,7 @@
 import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import { isSafeFileStem } from '../util/filename.js'
 
 /**
  * SessionStart 注入与书单登记(story-repo-spec §2.0)。
@@ -9,7 +10,21 @@ import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
  * 读侧 + 扫描重建自愈(M4)与写侧(登记/换书/最后打开,M5)同模块,books.jsonl 格式单源。
  */
 
-/** 读 .webnovel/books.jsonl,逐行 JSON,损坏行跳过并计数 */
+/**
+ * books.jsonl 行形状校验(P3-F11):JSON 能解不等于能账本——
+ * 形状非法的行(`目录` 含 `/`、`\`、`..`、绝对路径、Windows 保留名、非字符串,
+ * 或 `书名` 非非空字符串)一律判 corrupt 进既有自愈回写通道。
+ * 判据=「目录是单层安全文件名干」(isSafeFileStem——P0 单源,不另建黑名单;
+ * JSONL 行是机器账本字段不是语义标的,与白名单文件干同族)。写侧 registerBook 同款口径。
+ */
+function isValidBookEntry(entry) {
+  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false
+  if (typeof entry.书名 !== 'string' || !entry.书名.trim()) return false
+  if (typeof entry.目录 !== 'string' || !entry.目录) return false
+  return isSafeFileStem(entry.目录)
+}
+
+/** 读 .webnovel/books.jsonl,逐行 JSON,损坏行跳过并计数(P3-F11:形状非法行同计) */
 export async function readBooksRegistry(workdir) {
   const p = path.join(workdir, '.webnovel', 'books.jsonl')
   let content
@@ -23,11 +38,14 @@ export async function readBooksRegistry(workdir) {
   for (const line of content.split('\n')) {
     const t = line.trim()
     if (!t) continue
+    let entry = null
     try {
-      books.push(JSON.parse(t))
+      entry = JSON.parse(t)
     } catch {
-      corrupt++
+      entry = null
     }
+    if (entry && isValidBookEntry(entry)) books.push(entry)
+    else corrupt++
   }
   return { ok: true, missing: false, books, corrupt }
 }
@@ -105,9 +123,20 @@ function localDate() {
 
 /**
  * 登记一本书并置为当前(建书流程调用)。同目录已登记则更新书名,不产生重复行。
+ * P3-F11 写侧校验:`书名` 非空字符串、`目录` 为单层安全文件名干——
+ * 否则一行坏数据就能让下游 `git clean -fd` / `fs.rm` 落到工作目录外(与读侧同口径单源判定)。
  */
 export async function registerBook(workdir, { 书名, 目录 }) {
-  if (!书名 || !目录) return { ok: false, error: '登记需要 书名 与 目录', books: [] }
+  if (typeof 书名 !== 'string' || !书名.trim()) {
+    return { ok: false, error: '登记需要非空字符串的 书名 字段', books: [] }
+  }
+  if (typeof 目录 !== 'string' || !isSafeFileStem(目录)) {
+    return {
+      ok: false,
+      error: `目录「${typeof 目录 === 'string' && 目录 ? 目录 : '(空)'}」不合法:须为单层目录名,不能含路径分隔符、绝对路径或「..」(P3-F11 安全校验)`,
+      books: [],
+    }
+  }
   const { books } = await loadBooks(workdir)
   const kept = books.filter((b) => b.目录 !== 目录).map((b) => ({ ...b, 当前: false }))
   kept.push({ 书名, 目录, 当前: true, 最后打开: localDate() })

+ 75 - 2
v7/src/staging/contract-invalidation.js

@@ -12,7 +12,80 @@ import { RETRY_POLICY_PATH } from '../retry-policy/index.js'
 
 export const CONTRACT_INVALIDATION_MARKER = path.join('工作区', '契约更新待重备料.md')
 export const CONTRACT_INVALIDATION_GUARD = path.join('工作区', '契约失效.json')
-export const CONTRACT_UPDATE_LOCK = path.join('工作区', '契约更新处理中.lock')
+// 产品契约口径:仓库相对路径恒为正斜杠字面量(含 Windows)——DTO 面向 AI/宿主展示的
+// 路径与 persistRepair 白名单比对的口径都必须一致(P3-F9/R4)。
+export const CONTRACT_UPDATE_LOCK = '工作区/契约更新处理中.lock'
+
+/**
+ * 陈旧锁年龄兜底阈值(P3-F9):锁文件缺 pid 或进程探测不可达时,仅靠时间判定。
+ * 取 30 分钟——正当写操作(含大批量定稿)远低此数;这是无 pid 可判活锁时的兜底,
+ * 有 pid 信息时一律以 pid 存活判定为准,与时间无关。
+ */
+export const STALE_CONTRACT_LOCK_AGE_MS = 30 * 60 * 1000
+
+/**
+ * 读并判定契约互斥锁是否陈旧(P3-F9)。进程被 kill/断电后锁文件永久残留会堵死全部写路径。
+ * 判定双判:pid 存活探测(process.kill(pid, 0):ESRCH=进程不在=陈旧;EPERM/无异常=活锁);
+ * pid 缺失/解析失败时才退回 startedAt/文件 mtime 年龄阈值。
+ * @param {string} repoPath
+ * @returns {Promise<{stale: boolean, reason: string, error: string, pid: number|null, startedAt: string|null}>}
+ *   stale=false 时 reason 为 ''(无锁或活锁);stale=true 时 reason 为人话理由(供电/ui 序0呈报)。
+ */
+export async function readStaleContractLock(repoPath) {
+  const none = { stale: false, reason: '', error: '', pid: null, startedAt: null }
+  const lockPath = path.join(repoPath, CONTRACT_UPDATE_LOCK)
+  let raw
+  try {
+    raw = await fs.readFile(lockPath, 'utf8')
+  } catch {
+    return none // 无锁文件
+  }
+
+  let meta = {}
+  try {
+    meta = JSON.parse(raw)
+  } catch {
+    /* 损坏锁文件:走年龄兜底 */
+  }
+  const pid = Number.isInteger(meta?.pid) ? meta.pid : null
+
+  if (pid !== null) {
+    try {
+      process.kill(pid, 0) // 探测:不杀进程,只看存活
+      return { ...none, pid, startedAt: meta?.startedAt ?? null } // 活锁:不可判陈旧
+    } catch (err) {
+      if (err.code === 'ESRCH') {
+        return {
+          stale: true,
+          reason: `锁的持有进程(pid ${pid})已退出但锁文件残留(上次中断未清理),${CONTRACT_UPDATE_LOCK} 会一直阻塞写操作,可安全清理`,
+          error: '',
+          pid,
+          startedAt: meta?.startedAt ?? null,
+        }
+      }
+      // EPERM 等:探测不可达视为活锁(保守不误删)
+      return { ...none, pid, startedAt: meta?.startedAt ?? null }
+    }
+  }
+
+  // 无 pid 信息:年龄兜底(锁文件 mtime 比 startedAt 更接近残留时刻)
+  try {
+    const st = await fs.stat(lockPath)
+    if (Date.now() - st.mtimeMs > STALE_CONTRACT_LOCK_AGE_MS) {
+      return {
+        stale: true,
+        reason: `锁文件超龄残留(超过 ${Math.round(STALE_CONTRACT_LOCK_AGE_MS / 60000)} 分钟无变化)且无法确认持有进程,可安全清理`,
+        error: '',
+        pid: null,
+        startedAt: meta?.startedAt ?? null,
+      }
+    }
+  } catch {
+    // stat 失败按无锁处理
+  }
+  return none
+}
+
 
 const BATCH_DIR = path.join('工作区', '待定稿')
 const GUARD_KEYS = Object.freeze([
@@ -44,7 +117,7 @@ export async function acquireContractMutationLock(repoPath, operation = '作品
     if (err.code === 'EEXIST') {
       return {
         ok: false,
-        error: `已有作品状态写入正在处理,${operation}不能并发执行;请等待该次完成后重试。`,
+        error: `已有作品状态写入正在处理,${operation}不能并发执行;请等待该次完成后重试。锁文件:${CONTRACT_UPDATE_LOCK}——若怀疑是上次中断残留的陈旧锁,先运行 next 待序0 提议清理并确认。`,
       }
     }
     return { ok: false, error: `${operation}无法建立并发保护:${err.message}` }

+ 2 - 1
v7/src/state-machine/dto.js

@@ -38,7 +38,8 @@ export async function buildDto(ctx, 序, base = {}) {
       return {
         state: 'repair-confirm',
         failures: base.failures || [],
-        期望产物: '逐个给出「保留作者意图」的修复方案,作者确认后由 M3 写回',
+        期望产物:
+          '逐个给出「保留作者意图」的修复方案,作者确认后由 M3 写回。failures 中带 `action: "delete"` 的项是陈旧锁清理——不产内容,作者确认后 persist-repair 直接删(白名单仅限该锁路径,不得推广成任意删除)。',
       }
     case 1: {
       const routes = ctx.packageRoot ? await loadRoutes(ctx.packageRoot) : []

+ 13 - 5
v7/src/state-machine/git-health.js

@@ -2,7 +2,10 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { createGit } from '../finalize/git.js'
 
-const STALE_LOCK_MS = 3000
+// P3-F10:3s 太短——杀软/Windows 索引器对 .git 的瞬时读锁常常超 3s,
+// 曾被误判陈旧删除,引入双 git 进程并发写损坏窗口。git 的 index.lock 不含 pid
+//(无法做活锁进程判定),退而求其次=分钟级阈值 + 删除前二次 stat 防 TOCTOU。
+export const STALE_LOCK_MS = 60_000
 
 /**
  * git 健康检查(spec §10 第 0 步前)。D2:激进自动修 + 可恢复安全网。
@@ -22,12 +25,17 @@ export async function checkGitHealth(ctx) {
   try {
     const st = await fs.stat(lockPath)
     if (Date.now() - st.mtimeMs > STALE_LOCK_MS) {
-      await fs.rm(lockPath, { force: true })
-      fixed.push('删除了陈旧的 git 锁文件(.git/index.lock,残留自上次中断的 git 操作)')
-      rescued.push('index.lock 已删')
+      // P3-F10 删除前二次 stat 防 TOCTOU:判陈旧到 rm 的窗口里若锁被新 git 操作刷新(mtime 变近),
+      // 说明是活锁,宁可保守不删,不能误删别人的锁。
+      const recheck = await fs.stat(lockPath)
+      if (Date.now() - recheck.mtimeMs > STALE_LOCK_MS) {
+        await fs.rm(lockPath, { force: true })
+        fixed.push('删除了陈旧的 git 锁文件(.git/index.lock,残留自上次中断的 git 操作)')
+        rescued.push('index.lock 已删')
+      }
     }
   } catch {
-    // 无锁文件
+    // 无锁文件(或二次 stat 时已被持有方自然释放)
   }
 
   // 2. .git 损坏:坏了别再动 git(D2 例外,只指引)

+ 15 - 0
v7/src/state-machine/index.js

@@ -2,6 +2,10 @@ import { checkGitHealth } from './git-health.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 import { buildDto } from './dto.js'
 import * as d from './detectors.js'
+import {
+  CONTRACT_UPDATE_LOCK,
+  readStaleContractLock,
+} from '../staging/contract-invalidation.js'
 
 /**
  * 状态机单入口(spec §10):先跑 git 健康检查,再按序 0-6 命中即停判定下一步。
@@ -21,6 +25,17 @@ export async function determineNextState(ctx) {
 
   // 序0 修复确认(检测=脚本,提议=AI)
   const failures = await d.detectParseFailures(repoPath)
+  // P3-F9:契约互斥锁陈旧残留——进程被 kill/断电后锁永远堵死写路径。
+  // 检测成独立 failure(file=锁路径,走 persistRepair 的 delete 行动);不是静默自动回收(保留了作者确认)。
+  const staleLock = await readStaleContractLock(repoPath)
+  if (staleLock.stale) {
+    failures.push({
+      file: CONTRACT_UPDATE_LOCK,
+      error: staleLock.reason,
+      action: 'delete',
+      修复指引: '锁的持有进程已不在,锁文件残留只会阻塞写路径。作者确认后可经 persist-repair action=delete 直接删除(不需要 AI 生成内容)。',
+    })
+  }
   // 作品契约缺/坏由序4、序6的同一 ContractReader 阻断,不送进 AI 修复或创作态。
   // 若还有其他源文件错误,仍保持既有序0修复确认语义。
   const repairFailures = failures.filter(

+ 27 - 2
v7/src/state-machine/persist.js

@@ -362,25 +362,50 @@ function validateRepairContent(file, content) {
   return r.ok ? { ok: true } : { ok: false, error: r.error }
 }
 
+/**
+ * P3-F9:陈旧锁 delete 的唯一放行面。白名单钳死——只有契约互斥锁可经 persistRepair
+ * 删除,防 AI 把 delete 推广成任意删。新增其他可删路径必须先加测试并在这里显式登记。
+ */
+export const REPAIR_DELETE_ALLOWED = Object.freeze(['工作区/契约更新处理中.lock'])
+
 /**
  * 序0 修复确认 → 写回修复后的源文件。安全网:
  * 只写在 allowedFiles(M3 检测到的失败清单)内的文件;修复内容必须能解析,否则不写。
+ * P3-F9 起支持 `action: 'delete'` 项删除陈旧锁(仅 REPAIR_DELETE_ALLOWED 内路径)。
  */
 export async function persistRepair(ctx, { repairs }, { allowedFiles = [] } = {}) {
+  const deletes = []
+  const writes = []
   for (const r of repairs) {
     if (!allowedFiles.includes(r.file)) {
       return { ok: false, written: [], error: `拒绝写入非失败清单文件:${r.file}` }
     }
+    if (r.action === 'delete') {
+      if (!REPAIR_DELETE_ALLOWED.includes(r.file)) {
+        return { ok: false, written: [], error: `拒绝删除非白名单路径:${r.file}(delete 只放行契约锁清理)` }
+      }
+      deletes.push(r.file)
+      continue
+    }
     const check = validateRepairContent(r.file, r.content)
     if (!check.ok) {
       return { ok: false, written: [], error: `修复内容仍解析失败(${r.file}):${check.error}` }
     }
+    writes.push(r)
   }
   try {
-    const written = await writeAtomicBatch(
+    // 删除先行(不属 writeAtomicBatch 的语义面——原子批只管写)。
+    // 顺序要求:先解锁(让互斥路径恢复)再写回,避免修复件被陈旧锁挡住。
+    const written = []
+    for (const rel of deletes) {
+      await fs.rm(path.join(ctx.repoPath, rel), { force: true })
+      written.push(rel)
+    }
+    const batchWritten = await writeAtomicBatch(
       ctx.repoPath,
-      repairs.map((r) => ({ path: r.file, content: r.content }))
+      writes.map((r) => ({ path: r.file, content: r.content }))
     )
+    written.push(...batchWritten)
     // 修复件此前因解析失败不在缓存,写完必须自刷,否则报表/备料继续缺数据。
     // 修复本身不 commit:入档走序2 手改补登(relink),与 spec §9 的手改通道一致。
     const cacheRefresh = await refreshCacheAfterSourceChange(ctx)

+ 33 - 0
v7/src/storage/serializers/yaml-dialect.js

@@ -16,6 +16,10 @@ export function serializeYAML(data) {
   const lines = []
 
   for (const [key, value] of Object.entries(data)) {
+    // P3-F8:key 零校验时 `:`/`\n`/控制字符可以注入 front matter 结构(AI 可控 updates 键直达)。
+    // 与嵌套检测同款姿势:人话 throw 并指认是哪个 key,校验拒绝而非静默改写。
+    assertSafeYamlKey(key)
+
     // 检测嵌套映射(违反防呆方言)
     if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
       throw new Error(`防呆方言禁止嵌套映射:字段「${key}」的值是对象。所有字段必须平铺到顶层。`)
@@ -39,6 +43,35 @@ export function serializeYAML(data) {
   return lines.join('\n')
 }
 
+/**
+ * key 合法性校验(P3-F8):key 会原样写进 `key: value` 行首,
+ * 含 `:`(冒号切分出第二对键值)、`\n`(续行造新行=新字段)、`#`(行内注释吞尾)、
+ * 首尾空白(序列化后被裁,读回对不上)、引号引导(换标量形态)、控制字符(解析器炸)
+ * 的 key 都是注入面。合法 key = 非空、无控制字符、无冒号/井号/首尾空白、非引号引导。
+ * @param {string} key
+ */
+function assertSafeYamlKey(key) {
+  if (typeof key !== 'string' || key === '') {
+    throw new Error(`防呆方言收到空字段名(非法 key),拒绝序列化`)
+  }
+  // eslint-disable-next-line no-control-regex
+  if (/[\x00-\x1f]/.test(key)) {
+    throw new Error(`字段名「${JSON.stringify(key)}」含控制字符(含换行),会注入 front matter 结构,拒绝序列化`)
+  }
+  if (key !== key.trim()) {
+    throw new Error(`字段名「${key}」含首尾空白(序列化后读回对不上),拒绝序列化`)
+  }
+  if (key.includes(':')) {
+    throw new Error(`字段名「${key}」含冒号(YAML 键值分隔符,会切出新字段),拒绝序列化`)
+  }
+  if (key.includes('#')) {
+    throw new Error(`字段名「${key}」含井号(行内注释起点,后半截会被吞掉),拒绝序列化`)
+  }
+  if (/^["'“‘”’]/.test(key)) {
+    throw new Error(`字段名「${key}」以引号开头(会改变标量形态),拒绝序列化`)
+  }
+}
+
 /**
  * 序列化单个值(判断是否需要引号)。
  * @param {any} value

+ 65 - 0
v7/test/session/session.test.js

@@ -8,6 +8,8 @@ import {
   scanRebuildBooks,
   assembleSessionContext,
   writeBooksRegistry,
+  loadBooks,
+  registerBook,
 } from '../../src/session/index.js'
 
 async function tmpWorkdir() {
@@ -38,6 +40,69 @@ test('readBooksRegistry:解析合法行,损坏行跳过并计数', async () =>
   } finally { await cleanup() }
 })
 
+// P3-F11:JSON 能解但形状非法(目录穿越/非字符串/非单层)的行必须算 corrupt——
+// 否则下游会把 `目录: "../../x"` 当成合法书目录消费(git clean/reset 落到工作目录外)。
+test('readBooksRegistry(P3-F11):形状非法的行计入 corrupt(越界目录不进书单)', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [
+      JSON.stringify({ 书名: '剑起青云', 目录: '剑起青云', 当前: true }),
+      JSON.stringify({ 书名: 'x', 目录: '../../etc/passwd' }),
+      JSON.stringify({ 书名: 'x', 目录: 'a/b' }),
+      JSON.stringify({ 书名: 'x', 目录: 'a\\b' }),
+      JSON.stringify({ 书名: 'x', 目录: '..', 当前: true }),
+      JSON.stringify({ 书名: 'x', 目录: 123 }),
+      JSON.stringify({ 书名: 'x' }),
+      JSON.stringify(['不是对象']),
+      JSON.stringify({ 书名: '', 目录: 'y' }),
+      JSON.stringify({ 书名: '  ', 目录: 'y' }),
+      JSON.stringify({ 书名: '正常', 目录: '.hidden' }),
+    ])
+    const r = await readBooksRegistry(root)
+    assert.equal(r.ok, true)
+    assert.deepEqual(
+      r.books.map((b) => b.书名),
+      ['剑起青云'],
+      `形状非法的行必须全部拦截,实际通过:${JSON.stringify(r.books)}`
+    )
+    assert.equal(r.corrupt, 10, `10 行非法须全计数,实际 ${r.corrupt}`)
+  } finally { await cleanup() }
+})
+
+test('loadBooks(P3-F11):形状非法的行走到既有 corrupt+自愈回写通道(好行保留 坏行清除)', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [
+      JSON.stringify({ 书名: '剑起青云', 目录: '剑起青云', 当前: true }),
+      JSON.stringify({ 书名: 'x', 目录: '../../evil' }),
+    ])
+    const r = await loadBooks(root)
+    assert.equal(r.ok, true)
+    assert.equal(r.books.length, 1)
+    assert.equal(r.books[0].书名, '剑起青云')
+    // 自愈回写:坏行应从文件里被清掉
+    const content = await fs.readFile(path.join(root, '.webnovel', 'books.jsonl'), 'utf8')
+    for (const line of content.trim().split('\n')) {
+      const b = JSON.parse(line)
+      assert.equal(b.书名, '剑起青云', '自愈回写后只剩好行')
+    }
+  } finally { await cleanup() }
+})
+
+test('registerBook(P3-F11):越界/非法目录写侧即拒(不污染书单)', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    for (const bad of ['../../x', 'a/b', 'a\\b', '/abs/path', 'C:\\x', '..', '.hidden', 'CON']) {
+      const r = await registerBook(root, { 书名: 'x', 目录: bad })
+      assert.equal(r.ok, false, `目录「${bad}」必须被拒`)
+      assert.match(r.error || '', /目录/, `报错要点名字段:${bad}`)
+    }
+    // 合法目录照常登记
+    const good = await registerBook(root, { 书名: '剑起青云', 目录: '剑起青云' })
+    assert.equal(good.ok, true, good.error)
+  } finally { await cleanup() }
+})
+
 test('readBooksRegistry:缺文件 → missing=true 不抛', async () => {
   const { root, cleanup } = await tmpWorkdir()
   try {

+ 89 - 0
v7/test/staging/contract-lock.test.js

@@ -0,0 +1,89 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import {
+  acquireContractMutationLock,
+  readStaleContractLock,
+  STALE_CONTRACT_LOCK_AGE_MS,
+  CONTRACT_UPDATE_LOCK,
+} from '../../src/staging/contract-invalidation.js'
+import { makeRepo, cleanup } from '../storage/_tmprepo.js'
+
+/**
+ * P3-F9:契约互斥锁陈旧回收(S0 检测+序0 确认流,非静默回收)。
+ * 锁文件内容 {pid, operation, startedAt}(既有写入方);读侧判定:pid 死→陈旧;
+ * 无 pid 信息/探测失败→超龄阈值兜底;pid 存活→活锁保持拒绝(不得误删)。
+ */
+
+async function writeLock(root, fields) {
+  const p = path.join(root, CONTRACT_UPDATE_LOCK)
+  await fs.mkdir(path.dirname(p), { recursive: true })
+  await fs.writeFile(p, JSON.stringify(fields), 'utf8')
+  return p
+}
+
+test('readStaleContractLock:pid 已死 → 陈旧', async () => {
+  const root = await makeRepo()
+  try {
+    // 找一个肯定不存在的 pid:从当前 pid 往上找空位
+    let deadPid = process.pid + 100000
+    try { process.kill(deadPid, 0) } catch { /* ESRCH 即确认已死 */ }
+    await writeLock(root, { pid: deadPid, operation: '章节定稿', startedAt: new Date().toISOString() })
+    const r = await readStaleContractLock(root)
+    assert.equal(r.stale, true, r.error)
+    assert.match(r.reason, /进程.*不?在|已退出|pid/i)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('readStaleContractLock:pid 存活(自身)→ 活锁,绝不可判陈旧', async () => {
+  const root = await makeRepo()
+  try {
+    await writeLock(root, { pid: process.pid, operation: '章节定稿', startedAt: new Date(Date.now() - 7200_000).toISOString() })
+    const r = await readStaleContractLock(root)
+    assert.equal(r.stale, false, '活锁即使超龄也不得判陈旧(误删=并发写损坏窗口)')
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('readStaleContractLock:无 pid 字段 + 超龄(>阈值)→ 年龄兜底判陈旧', async () => {
+  const root = await makeRepo()
+  try {
+    const p = await writeLock(root, { operation: '章节定稿', startedAt: new Date(Date.now() - STALE_CONTRACT_LOCK_AGE_MS - 60_000).toISOString() })
+    const old = new Date(Date.now() - STALE_CONTRACT_LOCK_AGE_MS - 60_000)
+    await fs.utimes(p, old, old)
+    const r = await readStaleContractLock(root)
+    assert.equal(r.stale, true)
+    assert.match(r.reason, /超龄|陈旧|残留/)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('readStaleContractLock:无锁文件 → 不陈旧', async () => {
+  const root = await makeRepo()
+  try {
+    const r = await readStaleContractLock(root)
+    assert.equal(r.stale, false)
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('acquireContractMutationLock:并发被拒时报文带锁路径与序0 清理指引', async () => {
+  const root = await makeRepo()
+  try {
+    const first = await acquireContractMutationLock(root, '章节定稿')
+    assert.equal(first.ok, true)
+    const second = await acquireContractMutationLock(root, '章级知识归档')
+    assert.equal(second.ok, false)
+    assert.match(second.error, /工作区.{0,2}契约更新处理中\.lock|契约更新处理中\.lock/)
+    assert.match(second.error, /陈旧|清理/)
+    await first.release()
+  } finally {
+    await cleanup(root)
+  }
+})

+ 38 - 0
v7/test/state-machine/git-health.test.js

@@ -6,6 +6,7 @@ import { promises as fs } from 'node:fs'
 import { execFile } from 'node:child_process'
 import { promisify } from 'node:util'
 import { checkGitHealth } from '../../src/state-machine/git-health.js'
+import { STALE_LOCK_MS } from '../../src/state-machine/git-health.js'
 
 const execFileAsync = promisify(execFile)
 
@@ -41,6 +42,43 @@ test('git健康:陈旧锁文件自动删 + 救援记录', async () => {
   }
 })
 
+// P3-F10:阈值 3s→60s——杀软/索引器瞬时持锁超 3s 曾被误删(双 git 进程并发写损坏窗口)。
+// git 的 index.lock 不含 pid(无法判活锁),退而求其次=分钟级阈值 + 删除前二次 stat 防 TOCTOU。
+test('P3-F10:分钟级阈值内的新鲜锁绝不误删(杀软/索引器瞬时持锁场景)', async () => {
+  const { root } = await makeGitRepo()
+  try {
+    const lock = path.join(root, '.git', 'index.lock')
+    await fs.writeFile(lock, '', 'utf8')
+    // 5 秒龄:旧 3s 阈值下会被误删(这正是 F10 的 bug 场景)
+    const recent = new Date(Date.now() - 5000)
+    await fs.utimes(lock, recent, recent)
+    const r = await checkGitHealth({ repoPath: root })
+    assert.equal(r.ok, true)
+    assert.ok(STALE_LOCK_MS > 5000, `阈值须 >5s(实测 ${STALE_LOCK_MS}ms),否则本测试无意义`)
+    assert.ok(STALE_LOCK_MS >= 60_000, `阈值须提到分钟级(实测 ${STALE_LOCK_MS}ms)`)
+    assert.ok(!r.fixed.some((m) => m.includes('锁文件')), '5s 新鲜锁不得被判陈旧删除')
+    await fs.access(lock)
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test('P3-F10:分钟级超龄锁(90 秒)才删(陈旧判定仍有牙齿)', async () => {
+  const { root } = await makeGitRepo()
+  try {
+    const lock = path.join(root, '.git', 'index.lock')
+    await fs.writeFile(lock, '', 'utf8')
+    const old = new Date(Date.now() - 90_000)
+    await fs.utimes(lock, old, old)
+    const r = await checkGitHealth({ repoPath: root })
+    assert.equal(r.ok, true)
+    assert.ok(r.fixed.some((m) => m.includes('锁文件')), '90s 超龄锁必须删除')
+    await assert.rejects(() => fs.access(lock))
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
 test('git健康:网盘冲突副本归档不删', async () => {
   const { root } = await makeGitRepo()
   try {

+ 60 - 0
v7/test/state-machine/persist.test.js

@@ -277,6 +277,66 @@ test('persistRepair(序0)→ 仅写失败清单内的文件,内容须能解
   } finally { await cleanup() }
 })
 
+// P3-F9:序0 陈旧锁清理走 persistRepair 的 delete 动作(白名单钳死,只放行契约锁路径的删除)。
+test('persistRepair(P3-F9):action=delete 删契约锁(白名单含该路径)', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const lockRel = '工作区/契约更新处理中.lock'
+    await fs.mkdir(path.join(root, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(root, lockRel), '{"pid":999999,"operation":"定稿"}', 'utf8')
+    const r = await persistRepair(
+      ctx,
+      { repairs: [{ file: lockRel, action: 'delete' }] },
+      { allowedFiles: [lockRel] }
+    )
+    assert.equal(r.ok, true, r.error)
+    assert.deepEqual(r.written, [lockRel])
+    await assert.rejects(() => fs.access(path.join(root, lockRel)), '锁必须被删除')
+  } finally { await cleanup() }
+})
+
+test('persistRepair(P3-F9):action=delete 不在白名单照样拒;删非锁路径也拒', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const notInList = await persistRepair(
+      ctx,
+      { repairs: [{ file: '工作区/契约更新处理中.lock', action: 'delete' }] },
+      { allowedFiles: ['定稿/正文/0001-起.md'] }
+    )
+    assert.equal(notInList.ok, false, 'delete 也必须在 allowedFiles 内')
+
+    const arbitrary = await persistRepair(
+      ctx,
+      { repairs: [{ file: '定稿/正文/0001-起.md', action: 'delete' }] },
+      { allowedFiles: ['定稿/正文/0001-起.md', '工作区/契约更新处理中.lock'] }
+    )
+    assert.equal(arbitrary.ok, false, 'delete 只放行契约锁路径,不得删任意文件')
+  } finally { await cleanup() }
+})
+
+test('persistRepair(P3-F9):delete 与正常 repairs 同批共存', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const lockRel = '工作区/契约更新处理中.lock'
+    const target = '定稿/正文/0001-起.md'
+    await fs.mkdir(path.join(root, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(root, lockRel), '{}', 'utf8')
+    await fs.mkdir(path.join(root, '定稿/正文'), { recursive: true })
+    await fs.writeFile(path.join(root, target), '---\n坏: yaml: :\n---\n正文', 'utf8')
+    const r = await persistRepair(
+      ctx,
+      { repairs: [
+        { file: lockRel, action: 'delete' },
+        { file: target, content: '---\n章号: 1\n标题: 起\n---\n正文' },
+      ] },
+      { allowedFiles: [lockRel, target] }
+    )
+    assert.equal(r.ok, true, r.error)
+    await assert.rejects(() => fs.access(path.join(root, lockRel)))
+    assert.match(await read(root, target), /章号: 1/)
+  } finally { await cleanup() }
+})
+
 test('persistRepair:拒绝写不在失败清单内的文件(安全网,防 AI 任意写)', async () => {
   const { ctx, root, cleanup } = await tmpRepo()
   try {

+ 49 - 0
v7/test/state-machine/router.test.js

@@ -111,6 +111,55 @@ test('序0:源文件解析失败 → 修复确认', async () => {
   }
 })
 
+// P3-F9:陈旧契约锁(持有进程已死)→ 序0 修复确认里带 delete 指引的独立项
+test('序0(P3-F9):陈旧契约锁 → failures 含 delete 动作与清理指引', async () => {
+  const { ctx, root, cleanup } = await makeGitBook(healthyBook())
+  try {
+    // 放一个 pid 已死的锁(进程探测不可达的 pid)
+    const lockDir = path.join(root, '工作区')
+    await fs.mkdir(lockDir, { recursive: true })
+    let deadPid = process.pid + 100000
+    try { process.kill(deadPid, 0) } catch { /* ESRCH 即确认已死 */ }
+    await fs.writeFile(
+      path.join(lockDir, '契约更新处理中.lock'),
+      JSON.stringify({ pid: deadPid, operation: '章节定稿', startedAt: new Date().toISOString() }),
+      'utf8'
+    )
+
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 0, JSON.stringify(r.dto))
+    assert.equal(r.state, 'repair-confirm')
+    const lockFailure = r.dto.failures.find((f) => f.file === '工作区/契约更新处理中.lock')
+    assert.ok(lockFailure, 'failures 必须含契约锁项')
+    assert.equal(lockFailure.action, 'delete')
+    assert.match(lockFailure.修复指引, /persist-repair/)
+    assert.match(String(r.dto.期望产物), /delete/)
+  } finally {
+    await cleanup()
+  }
+})
+
+// P3-F9:活锁(pid 存活)不得被判陈旧——序0 不因它停下
+test('序0(P3-F9):活锁(持锁进程存活)不进 failures,不阻断流程', async () => {
+  const { ctx, root, cleanup } = await makeGitBook(healthyBook())
+  try {
+    const lockDir = path.join(root, '工作区')
+    await fs.mkdir(lockDir, { recursive: true })
+    await fs.writeFile(
+      path.join(lockDir, '契约更新处理中.lock'),
+      JSON.stringify({ pid: process.pid, operation: '章节定稿', startedAt: new Date().toISOString() }),
+      'utf8'
+    )
+
+    const r = await determineNextState(ctx)
+    assert.ok(!r.dto.failures || !r.dto.failures.some((f) => f.file === '工作区/契约更新处理中.lock'),
+      '活锁不得被判陈旧进 failures')
+    assert.notEqual(r.序, 0, '活锁不得触发序0')
+  } finally {
+    await cleanup()
+  }
+})
+
 test('序0:计划对象损坏 → 启动先进入修复确认', async () => {
   const { ctx, cleanup } = await makeGitBook(healthyBook({
     '大纲/创作设计/人物/CHAR-001-林晚.md': designFixture().replace('## 一致性边界', '## 边界缺失'),

+ 45 - 0
v7/test/storage/serializers/yaml-dialect.test.js

@@ -58,6 +58,51 @@ test('数字和布尔值正常输出', () => {
   assert.ok(yaml.includes('开关: true'))
 })
 
+// P3-F8:key 零校验可把 `:`/`\n`/控制字符注入 front matter 结构(AI 可控 updates 键直达)。
+// 防呆方言对非法形态的既有姿势是 throw 人话(嵌套映射先例),校验拒绝而非静默改写。
+test('P3-F8:注入型 key 一律人话 throw', () => {
+  const cases = [
+    ['换行键', { 'a\n读者已知: true': 'x' }, /\n|换行/],
+    ['冒号键', { 'a: b': 'x' }, /:/],
+    ['井号键', { 'a # b': 'x' }, /#/],
+    ['首尾空白键', { ' 隐患 ': 'x' }, /空白/],
+    ['引号引导键', { '"': 'x' }, /"|“|”/],
+    ['单引号引导键', { "'": 'x' }, /'|‘|’/],
+    ['空键', { '': 'x' }, /空/],
+    ['控制字符键', { 'a\x07b': 'x' }, /控制字符|\x07/],
+  ]
+  for (const [名称, data, errPattern] of cases) {
+    assert.throws(
+      () => serializeYAML(data),
+      (err) => errPattern.test(err.message) || /非法/.test(err.message),
+      `${名称} 应被拒`
+    )
+  }
+  // 每条错误文本必须指认是哪个 key(可定位)
+  try {
+    serializeYAML({ '坏\n键': 1 })
+    assert.unreachable('应已 throw')
+  } catch (err) {
+    assert.match(err.message, /坏/)
+  }
+})
+
+test('P3-F8:合法 key(含中文/连字符/数字/下划线)不受影响', () => {
+  const data = {
+    章号: 1,
+    标题: '正常',
+    'fast-pair': 'ok',
+    知识选择_版本: 'v2',
+    key3: 'x',
+  }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('章号: 1'))
+  assert.ok(yaml.includes('标题: 正常'))
+  assert.ok(yaml.includes('fast-pair: ok'))
+  assert.ok(yaml.includes('知识选择_版本: v2'))
+  assert.ok(yaml.includes('key3: x'))
+})
+
 test('嵌套映射抛错', () => {
   const data = { 外层: { 内层: '值' } }
   assert.throws(() => {