sillyspec 3.26.5 → 3.26.7

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.
@@ -129,8 +129,8 @@ execute 还有**第二道**独立的 stage 级审查:除逐 task review.json
129
129
  - **local.yaml `repos:` 段**:在 `.sillyspec/local.yaml` 注册跨仓仓路径(`main` 不用注册,隐式=当前项目):
130
130
  ```yaml
131
131
  repos:
132
- sillyspec: C:/Users/qinyi/IdeaProjects/sillyspec
133
132
  shared-lib: ../shared-lib
133
+ tool-repo: C:/path/to/your/tool-repo
134
134
  ```
135
135
  task 卡 `repo:` 引用的 key 必须在此注册,否则 execute 启动 fail-closed 阻断(跨仓 apply 走错仓=数据所有权事故,配置错误不降级)。
136
136
  - **跨仓 task 的 `allowed_paths`**:指**相对跨仓仓根**的路径(非主仓根)。
@@ -80,7 +80,7 @@ sillyspec knowledge inspect --id "generated/worktree-baseline-conflict"
80
80
  "zone": "manual",
81
81
  "path": "knowledge/patterns.md",
82
82
  "meta": {
83
- "author": "qinyi",
83
+ "author": "<git-user>",
84
84
  "created_at": "2026-06-19T12:40:00+08:00",
85
85
  "updated_at": "2026-06-24T10:00:00+08:00"
86
86
  },
@@ -100,8 +100,8 @@ plan 完成校验会检查:design 提到入口文件(cli.ts/main.ts/server.t
100
100
 
101
101
  ```yaml
102
102
  repos:
103
- sillyspec: C:/Users/qinyi/IdeaProjects/sillyspec
104
103
  shared-lib: ../shared-lib
104
+ tool-repo: C:/path/to/your/tool-repo
105
105
  ```
106
106
 
107
107
  key = task 卡 `repo:` 引用名,value = 跨仓仓绝对路径(或相对主仓根的路径)。`main` 不用注册。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.26.5",
3
+ "version": "3.26.7",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -31,9 +31,9 @@
31
31
  "@inquirer/prompts": "^7.10.1",
32
32
  "chalk": "^5.6.2",
33
33
  "chokidar": "^4.0",
34
- "js-yaml": "^4.2.0",
34
+ "js-yaml": "^4.3.1",
35
35
  "open": "^10.1",
36
36
  "ora": "^9.3.0",
37
- "ws": "^8.18"
37
+ "ws": "^8.21.3"
38
38
  }
39
39
  }
@@ -103,6 +103,13 @@ function isSillyspecPath(filePath) {
103
103
  return parts.includes('.sillyspec')
104
104
  }
105
105
 
106
+ // 凭据/敏感运行时文件:local.yaml 含 SillyHub token(connect 明文写入),.plan.md.lock 等锁文件
107
+ // 是并发原语非文档。/api/docs/content 无鉴权(仅 Origin 检查),这些文件绝不经 HTTP 暴露。
108
+ function isCredentialPath(filePath) {
109
+ const base = basename(resolve(filePath)).toLowerCase()
110
+ return base === 'local.yaml' || base === 'local.yml' || base.endsWith('.lock') || base === 'sillyspec-platform.json'
111
+ }
112
+
106
113
  function isViewableDocPath(filePath) {
107
114
  return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
108
115
  }
@@ -377,7 +384,7 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
377
384
  }
378
385
  // Security: only allow reading viewable text documents under a .sillyspec tree.
379
386
  const normalizedPath = resolve(filePath)
380
- if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath)) {
387
+ if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath) || isCredentialPath(normalizedPath)) {
381
388
  res.writeHead(403)
382
389
  res.end(JSON.stringify({ error: 'Access denied' }))
383
390
  return
package/src/db.js CHANGED
@@ -93,8 +93,10 @@ export class DB {
93
93
  * - 主库缺失/为空/损坏:尝试 .bak;成功则 warn 原因后 copy 回主库使用。
94
94
  * - 都不可用:主库不存在→建全新空库;主库曾存在(空/损坏)→fail-loud 抛错(不静默建空库吞进度)。
95
95
  * node:sqlite 打开 0 字节文件不抛错:主库须 statSync 显式判空(截断信号→走回退,防静默吞进度);
96
- * .bak 作为恢复源只要存在且能打开即用(含 0 字节空库——node:sqlite 视其为合法空库,
97
- * 生产环境 .bak 由原子写改名上一完整主库得来,永不为 0 字节,0 字节态仅出现在测试构造)。
96
+ * .bak 作为恢复源只要存在且能打开即用(含 0 字节空库——node:sqlite 视其为合法空库)。
97
+ * 注:node:sqlite 提交即持久化,不再写前备份主 .bak(sql.js 时代 _save/_atomicWriteSync 已随
98
+ * 2026-08-11 迁移移除);此恢复分支是向后兼容兜底,仅对 sql.js 时代遗留的 .bak 生效,
99
+ * 全新 node:sqlite 项目不产生主 .bak)。
98
100
  */
99
101
  _openWithFallback() {
100
102
  const bakPath = `${this.dbPath}.bak`;
package/src/fs-atomic.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
5
5
  * 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
6
6
  *
7
- * 注意:db.js 有自己的 _atomicWriteSync(含 .bak 备份),那是 DB 专属持久化逻辑,
8
- * 已经过稳定性验证,此处不复用、不替换——保持 db.js 零改动。
7
+ * 注意:DB 持久化由 node:sqlite 引擎承担(提交即落盘 sillyspec.db + WAL 侧车),
8
+ * 不经此处的 writeAtomicSync;sillyspec.db 不走原子写改名。
9
9
  */
10
10
  import { writeFileSync, renameSync, unlinkSync } from 'fs';
11
11
  import { dirname, basename, join } from 'path';
package/src/index.js CHANGED
@@ -247,6 +247,8 @@ async function main() {
247
247
  // 解析 --change 参数
248
248
  const progChangeIdx = args.indexOf('--change');
249
249
  const progChangeName = progChangeIdx >= 0 && args[progChangeIdx + 1] ? args[progChangeIdx + 1] : null;
250
+ // 与 run 入口同源消毒(防路径穿越;progress 下游 completeStage 写 history 文件名拼 change 名)
251
+ if (progChangeName) assertSafeChangeName(progChangeName, '--change 变更名');
250
252
 
251
253
  switch (subCommand) {
252
254
  case 'init':
@@ -353,6 +355,8 @@ async function main() {
353
355
  console.error('用法: sillyspec gate <stage> --change <name> [--json]\n stage: brainstorm | plan | execute | verify | archive | ...');
354
356
  process.exit(2);
355
357
  }
358
+ // 与 run 入口同源消毒(防路径穿越;gate 下游拼 marker/changes 路径)
359
+ assertSafeChangeName(gateChange, '--change 变更名');
356
360
  const { runGate } = await import('./machine-interface.js');
357
361
  // --spec-dir 透传 runGate.specBase(gate CLI 接线修复,P3 坑 3 sillyspec 侧)。
358
362
  // runGate 已分离 cwd(跑测试) + specBase(读 local.yaml/spec),但 gate case 之前
@@ -407,6 +411,8 @@ async function main() {
407
411
  console.error('用法: sillyspec derive <facet> --change <name> [--json]\n facet: execute-evidence | verify-test | task-reviews | artifacts');
408
412
  process.exit(2);
409
413
  }
414
+ // 与 run 入口同源消毒(防路径穿越;derive 下游拼 marker/changes 路径)
415
+ assertSafeChangeName(deriveChange, '--change 变更名');
410
416
  const { runDerive } = await import('./machine-interface.js');
411
417
  // --spec-dir 透传 runDerive.specBase(与 gate case 对称,P3 坑 3 sillyspec 侧)。
412
418
  // derive verify-test facet 内部同样调 runVerifyTestCheck 依赖 specBase,平台模式
@@ -454,6 +460,8 @@ async function main() {
454
460
  console.error('用法: sillyspec backfill-reviews --change <name> [--json] [--spec-dir <path>]\n 为手动补的 task 生成 review.json 草稿(cannot_verify),解 archive 客观完成度阻断');
455
461
  process.exit(2);
456
462
  }
463
+ // 与 run 入口同源消毒(防路径穿越;backfill 下游拼 marker/changes/review 路径)
464
+ assertSafeChangeName(brChange, '--change 变更名');
457
465
  const { generateTaskReviewDrafts } = await import('./task-review.js');
458
466
  // --spec-dir 透传 platformOpts.specRoot(与 gate/derive 对称);不传走默认 join(cwd,'.sillyspec')。
459
467
  const brPlatformOpts = {};
@@ -504,6 +512,8 @@ async function main() {
504
512
  console.error('用法: sillyspec register-stage-review --change <名> --stage <brainstorm|plan|execute> [--from <review.json>] [--spec-dir <path>] [--json]\n 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker),治 tier=independent marker 死锁');
505
513
  process.exit(2);
506
514
  }
515
+ // 与 run 入口同源消毒(防路径穿越;register 下游写 marker、拼 changes/review 路径)
516
+ assertSafeChangeName(rsrChange, '--change 变更名');
507
517
  const { registerStageReview } = await import('./stage-review.js');
508
518
  const rsrPlatformOpts = {};
509
519
  if (specDir) rsrPlatformOpts.specRoot = specDir;
@@ -1326,10 +1336,31 @@ SillySpec platform — SillyHub 平台同步
1326
1336
  }
1327
1337
  case 'resolve': {
1328
1338
  // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(绝不字段级 auto-merge)
1329
- const resolveName = platformArgs[0];
1339
+ // 变更名解析(顺序):--change <name>(与 pull/sync 兄弟命令写法一致)→ platformArgs 中
1340
+ // 第一个非 flag 位置参数 → 唯一未决冲突自动选中。修复:旧实现盲取 platformArgs[0],
1341
+ // flag 放前面时(resolve --keep-local --change x)把 '--keep-local' 当变更名去读冲突文件。
1342
+ const changeIdx = args.indexOf('--change');
1343
+ let resolveName = changeIdx >= 0 && args[changeIdx + 1] ? args[changeIdx + 1] : null;
1330
1344
  if (!resolveName) {
1331
- console.error('❌ 用法: sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>');
1332
- process.exit(1);
1345
+ resolveName = platformArgs.find((a) => a && !a.startsWith('--')) || null;
1346
+ }
1347
+ if (!resolveName) {
1348
+ // 无变更名:恰有一个未决冲突时自动选中,多个/零个则列出候选
1349
+ const pendingConflicts = syncModule.listConflictFiles(dir);
1350
+ if (pendingConflicts.length === 1) {
1351
+ resolveName = pendingConflicts[0].change;
1352
+ console.log(`ℹ️ 自动选中唯一未决冲突: ${resolveName}`);
1353
+ } else {
1354
+ console.error('❌ 用法: sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>');
1355
+ console.error(' 或: sillyspec platform resolve --change <name> <--keep-local|--take-platform|--abort>');
1356
+ if (pendingConflicts.length > 1) {
1357
+ console.error(` 现有 ${pendingConflicts.length} 个未决冲突,请指定变更名:`);
1358
+ for (const c of pendingConflicts) console.error(` - ${c.change}`);
1359
+ } else {
1360
+ console.error(' 当前无 sync-conflict 文件(可先运行 sillyspec platform status 查看)');
1361
+ }
1362
+ process.exit(1);
1363
+ }
1333
1364
  }
1334
1365
  // 解析 mode flag(三选一互斥,多传/不传均报错)
1335
1366
  const resolveFlags = ['--keep-local', '--take-platform', '--abort'].filter((f) => args.includes(f));
@@ -1344,6 +1375,12 @@ SillySpec platform — SillyHub 平台同步
1344
1375
  console.log(`✅ ${resolveName} [${resolveMode}]: ${r.reason}`);
1345
1376
  } else {
1346
1377
  console.error(`❌ ${resolveName}: ${r.reason}`);
1378
+ // 报错兜底:指定变更名无冲突文件时,列出 .runtime 下实际存在的冲突文件(防 flag 误当变更名再次误导)
1379
+ const pendingConflicts = syncModule.listConflictFiles(dir);
1380
+ if (pendingConflicts.length > 0 && !pendingConflicts.some((c) => c.change === resolveName)) {
1381
+ console.error(' 当前未决冲突:');
1382
+ for (const c of pendingConflicts) console.error(` - ${c.change}`);
1383
+ }
1347
1384
  process.exit(1);
1348
1385
  }
1349
1386
  break;
@@ -1673,7 +1710,7 @@ SillySpec config — local.yaml 配置键速查
1673
1710
  // 已知产物 → 用途说明。agent 据此判断每份产物能否作为证据/该读哪个。
1674
1711
  const KNOWN = {
1675
1712
  'sillyspec.db': '进度库 SQLite(stages/steps/changes 表)',
1676
- 'sillyspec.db.bak': '进度库备份(写前快照)',
1713
+ 'sillyspec.db.bak': '进度库备份(sql.js 时代写前快照遗留,node:sqlite 不再写入,仅 _openWithFallback 向后兼容恢复用)',
1677
1714
  'sillyspec.db.schema-version': '进度库 schema 版本标记',
1678
1715
  'doctor-diagnosis.json': 'doctor --json 结构化诊断快照',
1679
1716
  'doctor-dumps': 'doctor --dump-db 取证输出目录',
@@ -3,7 +3,7 @@
3
3
  // 调 pm.read/_write/_ensureDB/_getSpecDir/readGlobal/_runtimePath/listChanges/_appendAuditLog/
4
4
  // _renderBatchProgress(persistence-core + 其他组 delegate)。组内方法互调保持 this.X(同 class)。
5
5
  // completeStage 五层(resolve/validate/force/tx/history/print)整体搬迁,不拆流水线(保行为、最低风险)。
6
- import { mkdirSync } from 'fs';
6
+ import { mkdirSync, existsSync } from 'fs';
7
7
  import { join, resolve, basename } from 'path';
8
8
  import { writeAtomicSync } from '../fs-atomic.js';
9
9
  import { runValidators } from '../stage-contract.js';
@@ -140,21 +140,27 @@ export class StageMachine {
140
140
  console.log('');
141
141
  console.log(' ═══════════════════════════════════════');
142
142
  console.log(` 项目: ${(global?.project) || basename(cwd) || '(未命名)'}`);
143
- console.log(` 活跃变更: ${changes.length} 个`);
143
+ // 当前操作目标与活跃列表区分(坑 status-change-pointer-ambiguous):多活跃时不带
144
+ // --change 的 run/progress 不隐式选任何一个(index.js --change 解析多活跃报错),明确
145
+ // 说出来,防止把残留空壳(default/quick-xxx/目录缺失记录)误当操作目标跑错 change。
146
+ console.log(` 当前操作目标: (多活跃——不带 --change 时不隐式选定,run 须显式 --change <name>)`);
147
+ console.log(` 活跃变更记录: ${changes.length} 个(下列为 DB 中存在的活跃记录,非操作目标)`);
144
148
  console.log(' ═══════════════════════════════════════');
145
149
  console.log('');
146
150
 
151
+ const changesRoot = join(this.pm._getSpecDir(cwd), 'changes');
147
152
  for (const cn of changes) {
148
153
  const data = this.pm.read(cwd, cn);
154
+ const dirMissing = !existsSync(join(changesRoot, cn));
149
155
  if (!data) {
150
- console.log(` 📂 ${cn} — (无法读取)`);
156
+ console.log(` 📂 ${cn} — (无法读取)${dirMissing ? ' ⚠️ 目录缺失(残留记录,可用 doctor 清理)' : ''}`);
151
157
  continue;
152
158
  }
153
159
  const currentStage = data.currentStage || '(无)';
154
160
  const stageLabel = STAGE_LABELS[data.currentStage] || currentStage;
155
161
  const lastActive = data.lastActive ? this._timeAgo(data.lastActive) : '未知';
156
162
 
157
- console.log(` 📂 ${cn}`);
163
+ console.log(` 📂 ${cn}${dirMissing ? ' ⚠️ 目录缺失(残留记录,可用 doctor 清理)' : ''}`);
158
164
  console.log(` 当前阶段: ${stageLabel} 最近活跃: ${lastActive}`);
159
165
  console.log('');
160
166
  }
package/src/quicklog.js CHANGED
@@ -81,6 +81,14 @@ export function sanitizeDesc(description) {
81
81
  return s.length > 120 ? s.slice(0, 120) + '…' : s
82
82
  }
83
83
 
84
+ // git user.name → 文件名安全形式:白名单保留字母数字._- 与中文等 Unicode 字,
85
+ // 剥路径元字符(/ \ ..)与控制字符。防 QUICKLOG-<user>.md / 锁文件 / 轮转归档穿越写。
86
+ export function sanitizeQuicklogUser(user) {
87
+ const s = String(user || '').replace(/[\\/\r\n\0]/g, '').replace(/\.{2,}/g, '.').trim()
88
+ if (!s) return ''
89
+ return s.length > 64 ? s.slice(0, 64) : s
90
+ }
91
+
84
92
  // 从关联变更的 proposal/design 提取首个 # 标题(去「提案书(Proposal)— / 设计文档(Design)—」固定前缀)。
85
93
  // 让 quick --linked-changes 启动时(用户常不带 --input)也能拿到语义标题,而非 (quick 任务) 占位。
86
94
  // 读不到任何标题返回 ''(调用方再回退占位符,保持向后兼容)。
@@ -134,6 +142,9 @@ export function getQuickFileNotes() { return _pendingFileNotes }
134
142
  */
135
143
  export function parseFileNotes(raw) {
136
144
  if (!raw) return []
145
+ // note 单行化 + 限长:bullet 是「数组元素内嵌 \n」落盘(flipEntryInContent),note 带换行会伪造
146
+ // QUICKLOG 条目结构(\n## ql-… 可伪造后续条目头)。与 sanitizeDesc 同风格。
147
+ const sanitizeNote = (n) => n.replace(/[\r\n]+/g, ' ').trim().slice(0, 200)
137
148
  return String(raw)
138
149
  .split('||')
139
150
  .map((seg) => {
@@ -141,7 +152,7 @@ export function parseFileNotes(raw) {
141
152
  if (!s) return null
142
153
  const idx = s.indexOf('::')
143
154
  const path = (idx === -1 ? s : s.slice(0, idx)).replace(/\\/g, '/').trim()
144
- const note = idx === -1 ? '' : s.slice(idx + 2).trim()
155
+ const note = idx === -1 ? '' : sanitizeNote(s.slice(idx + 2))
145
156
  return path ? { path, note } : null
146
157
  })
147
158
  .filter(Boolean)
@@ -234,7 +245,7 @@ async function rotateIfNeeded(userFile, gitUser) {
234
245
  const dateStr = lastDate
235
246
  ? `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`
236
247
  : nowDatetime().slice(0, 10)
237
- const archiveFile = join(dirname(userFile), `QUICKLOG-${gitUser}-${dateStr}.md`)
248
+ const archiveFile = join(dirname(userFile), `QUICKLOG-${sanitizeQuicklogUser(gitUser) || 'unknown'}-${dateStr}.md`)
238
249
  // 同日已轮转过:两步各自原子化(append+clear 无法单原子,reader 容忍中间态——见 writeAtomic 注释)
239
250
  if (existsSync(archiveFile)) {
240
251
  const existing = readFileSync(archiveFile, 'utf8')
@@ -394,7 +405,9 @@ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes
394
405
  export async function allocateQuicklogEntry(specBase, gitUser, { description, linkedChanges = [], allowedFiles = [] } = {}) {
395
406
  const quicklogDir = join(specBase, 'quicklog')
396
407
  mkdirSync(quicklogDir, { recursive: true })
397
- const user = gitUser || 'unknown'
408
+ // git user.name 无字符限制,可含 / \ .. 等路径元字符(git config 或 .git/config 可控)——
409
+ // 白名单消毒防穿越写(QUICKLOG-<user>.md / 锁文件 / 轮转归档三处拼接),与 assertSafeChangeName 同风格
410
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
398
411
  const userFile = join(quicklogDir, `QUICKLOG-${user}.md`)
399
412
  const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
400
413
  const desc = sanitizeDesc(description)
@@ -437,7 +450,9 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
437
450
  */
438
451
  export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [] } = {}) {
439
452
  const quicklogDir = join(specBase, 'quicklog')
440
- const lockPath = join(quicklogDir, `.QUICKLOG-${(gitUser || 'unknown')}.md.lock`)
453
+ // 与 allocateQuicklogEntry 同源消毒:锁文件路径含 user(防穿越写,两入口必须一致否则锁不上同一文件)
454
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
455
+ const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
441
456
  const result = sanitizeResult(resultText)
442
457
  const linked = Array.isArray(linkedChanges) ? linkedChanges : []
443
458
  // 实际改动文件(调用方 complete-handlers 已用 isQuickMetadata 过滤 quick 自身元数据)。空 → 不动文件行。
@@ -13,8 +13,9 @@
13
13
  * - tier=self → 当前 agent 自审,CLI 放行(不要求 review.json)
14
14
  * - tier=independent → 强制独立审查子代理 + review.json,CLI 硬校验
15
15
  *
16
- * CLI 侧(run.js done gate)与 prompt 注入侧用同一个函数,保证两边判定一致——
17
- * 不信 agent 自报 tier。
16
+ * CLI 侧(run.js done gate)与 prompt 注入侧用同一个函数,保证两边判定一致。
17
+ * 判定权归 agent 的 plan_level 自主判断(CLI 只做确定性映射,见 classifyReviewTier 注释);
18
+ * 无 plan_level 的阶段才退文件数启发式。
18
19
  */
19
20
 
20
21
  import { parseFileChangeList } from './change-list.js'
@@ -28,14 +29,21 @@ export const SELF_REVIEW_FILE_THRESHOLD = 3
28
29
  /**
29
30
  * 审查分级
30
31
  *
32
+ * 判定权归 agent 的 plan_level 判定(2026-08-14 tier-plan-level 改造):plan_level 是 agent
33
+ * 在 plan step1 按 prompt 分级规则做的自主判断,tier 直接映射它——CLI 不再用文件数启发式
34
+ * 推翻 agent 判定(此前 light + 4 文件会被文件数强制 independent,两套标准打架且不透明,
35
+ * 实证:agent 判 light 自审通过,完成时 CLI 按 7 文件强制 independent review.json)。
36
+ *
31
37
  * 判定顺序(短路):
32
- * 1. plan_level === 'none' → self(极小变更)
33
- * 2. design.md 变更文件数 ≤ SELF_REVIEW_FILE_THRESHOLD → self
34
- * 3. 否则 → independent
38
+ * 1. plan_level === 'none' → self(极小变更)
39
+ * 2. plan_level === 'light' → self(agent 自主判定轻量,自审)
40
+ * 3. plan_level === 'full' → independent(full 语义即大变更需独立审查,CLI 确定性强制)
41
+ * 4. 无 plan_level(brainstorm 等阶段 plan.md 未生成)→ 退文件数启发式:
42
+ * design.md 变更文件数 ≤ SELF_REVIEW_FILE_THRESHOLD → self,否则 independent
35
43
  *
36
- * fail-safe:designPath 传了但解析不到文件清单(fileCount=0)按字面 0 处理(真无文件
37
- * 变更 = 极小);designPath 完全没传(fileCount=null,无法证明规模)→ independent
38
- * (宁严勿松)。
44
+ * fail-safe:无 plan_level 且 designPath 传了但解析不到文件清单(fileCount=0)按字面 0
45
+ * 处理(真无文件变更 = 极小);designPath 完全没传(fileCount=null,无法证明规模)→
46
+ * independent(宁严勿松)。
39
47
  *
40
48
  * @param {object} [opts]
41
49
  * @param {string} [opts.planLevel] - plan_level 分类结果(none/light/full)。brainstorm/propose 在 plan 之前,可能拿不到。
@@ -44,7 +52,13 @@ export const SELF_REVIEW_FILE_THRESHOLD = 3
44
52
  */
45
53
  export function classifyReviewTier({ planLevel, designPath } = {}) {
46
54
  if (planLevel === 'none') {
47
- return { tier: 'self', reason: `plan_level=none(极小变更,≤${SELF_REVIEW_FILE_THRESHOLD} 文件等价)`, fileCount: null }
55
+ return { tier: 'self', reason: 'plan_level=none(极小变更,agent 自主判定自审)', fileCount: null }
56
+ }
57
+ if (planLevel === 'light') {
58
+ return { tier: 'self', reason: 'plan_level=light(agent 自主判定轻量变更,自审)', fileCount: null }
59
+ }
60
+ if (planLevel === 'full') {
61
+ return { tier: 'independent', reason: 'plan_level=full(agent 自主判定大变更,独立审查防确认偏差)', fileCount: null }
48
62
  }
49
63
 
50
64
  let fileCount = null
@@ -53,13 +67,11 @@ export function classifyReviewTier({ planLevel, designPath } = {}) {
53
67
  }
54
68
 
55
69
  if (fileCount !== null && fileCount <= SELF_REVIEW_FILE_THRESHOLD) {
56
- return { tier: 'self', reason: `变更文件 ${fileCount} ≤ ${SELF_REVIEW_FILE_THRESHOLD}`, fileCount }
70
+ return { tier: 'self', reason: `无 plan_level,变更文件 ${fileCount} ≤ ${SELF_REVIEW_FILE_THRESHOLD}(启发式)`, fileCount }
57
71
  }
58
72
 
59
73
  const reason = fileCount === null
60
- ? (planLevel
61
- ? `plan_level=${planLevel},无法解析变更文件数,按正常规模处理`
62
- : '无法解析 design.md 变更文件清单,按正常规模处理')
63
- : `变更文件 ${fileCount} > ${SELF_REVIEW_FILE_THRESHOLD}`
74
+ ? '无法解析 design.md 变更文件清单,按正常规模处理(fail-safe)'
75
+ : `无 plan_level,变更文件 ${fileCount} > ${SELF_REVIEW_FILE_THRESHOLD}(启发式)`
64
76
  return { tier: 'independent', reason, fileCount }
65
77
  }
@@ -12,13 +12,17 @@
12
12
  * 依赖(completeStep 仅用已抽叶子 + 随搬 helper,零 run.js 闭包,零动态 import):
13
13
  * - shared.js: triggerSync/WAIT_MARKER_RE/getStageSteps;prompt.js: outputStep;gates.js: enforceDepsGate/runStageCompletionGates
14
14
  * - complete-handlers.js: 8 handler;stages/index.js: stageRegistry;worktree-apply.js: formatExecuteSummary
15
+ * - scan-profile.js: executeScanPreflight/executeScanPostcheck/computeScanProfile(noAI 硬门)
16
+ * - stages/plan-postcheck.js: executePlanPostcheck(noAI 硬门)
15
17
  * - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
16
18
  */
17
19
  import { join } from 'node:path'
18
20
  import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
19
21
  import { writeAtomicSync } from '../fs-atomic.js'
20
22
  import { withFileLock } from '../quicklog.js'
21
- import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext } from './shared.js'
23
+ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir } from './shared.js'
24
+ import { executeScanPreflight, executeScanPostcheck, computeScanProfile } from './scan-profile.js'
25
+ import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
22
26
  import { outputStep } from './prompt.js'
23
27
  import { enforceDepsGate, enforceReviewJsonGate, completeStageGates, readDesignScale } from './gates.js'
24
28
  import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
@@ -60,6 +64,32 @@ export function resolveWaitingStepWithAnswer(steps, doneAnswer, nowStr) {
60
64
  return waitIdx
61
65
  }
62
66
 
67
+ /**
68
+ * wait 选项单选校验(坑 wait-choice-enforcement):定义了 waitOptions 的 requiresWait/
69
+ * repeatableWait 步骤,--answer 必须命中预设选项之一——防止 agent 一句话代答绕过人工
70
+ * 选择(方案选择类 wait 的 answer 本就该是选项本身)。开放回答型步骤(澄清追问,answer
71
+ * 为自由文本)在 step 定义显式声明 waitFreeAnswer: true 豁免。
72
+ * 校验失败打印选项清单 + exit 1(fail-loud,与 requiresWait 门同风格)。
73
+ *
74
+ * waitOptions 优先取 stepDef(运行时构建的步骤定义,与 brainstorm/plan 静态定义同源),
75
+ * 回退 DB step entry(--continue 路径 waiting 步骤的 waitOptions 已落盘)。
76
+ */
77
+ function enforceWaitChoice(stepDef, stepEntry, answer, stageName, changeName) {
78
+ if (answer == null) return
79
+ const opts = Array.isArray(stepDef?.waitOptions) ? stepDef.waitOptions
80
+ : (Array.isArray(stepEntry?.waitOptions) ? stepEntry.waitOptions : null)
81
+ if (!opts || opts.length === 0) return
82
+ if (stepDef?.waitFreeAnswer === true) return
83
+ if (opts.includes(answer)) return
84
+ console.error(`❌ Step "${stepEntry?.name || '当前步骤'}" 是封闭单选 wait,--answer 必须命中预设选项之一(防 AI 代答绕过人工选择)。`)
85
+ console.error(` 你的 --answer: ${JSON.stringify(answer)}`)
86
+ console.error(` 可选值(复制其一): ${opts.join(' / ')}`)
87
+ console.error(` 自由文本回答的场景,该步骤应声明 waitFreeAnswer(当前未声明)。`)
88
+ if (changeName) console.error(` 重试:sillyspec run ${stageName} --done --answer "<选项>" --change ${changeName}`)
89
+ else console.error(` 重试:sillyspec run ${stageName} --done --answer "<选项>"`)
90
+ process.exit(1)
91
+ }
92
+
63
93
  export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
64
94
  const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false, isAllowDelete = false } = options
65
95
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
@@ -118,6 +148,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
118
148
  const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
119
149
  const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
120
150
  const currentStep = steps[currentIdx]
151
+ // --done --answer 解 waiting 的路径同样过单选校验(与下方 requiresWait doneAnswer 分支同规则)
152
+ if (_resolvedWaitIdx !== -1 && _doneAnswer) {
153
+ enforceWaitChoice(currentStepDef, currentStep, _doneAnswer, stageName, changeName)
154
+ }
121
155
  if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
122
156
  // 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
123
157
  const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
@@ -134,6 +168,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
134
168
  currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
135
169
  console.log(`⚠️ Step "${currentStep.name}" 的确认(${currentStepDef.waitReason})已在前置步骤完成,自动跳过。`)
136
170
  } else if (doneAnswer) {
171
+ enforceWaitChoice(currentStepDef, currentStep, doneAnswer, stageName, changeName)
137
172
  currentStep.status = 'waiting'
138
173
  currentStep.waitAnswer = doneAnswer
139
174
  currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
@@ -157,6 +192,27 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
157
192
  // 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
158
193
  await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
159
194
 
195
+ // ── noAI 步骤硬门(坑 noai-done-bypass):noAI 步骤的确定性校验不可被 --done 绕过 ──
196
+ // 正常路径 agent 跑 `run <stage>` 推进到 noAI step 时,runStage 自动执行 _cliAction
197
+ // (stage.js noAI 分支,不写 step output);若 agent 对 noAI step 直接 --done,此前
198
+ // completeStep 无 noAI 检测直接标 completed,executePlanPostcheck 等 CLI 确定性校验被
199
+ // 静默跳过(实证:multi-agent-platform 2026-08-13-spec-sync-visibility tasks/ 从未
200
+ // 生成但 plan 阶段 completed——step4 output 为 agent 口吻 178 字符而非 CLI 自动路径的
201
+ // 空 output)。修复:--done 落到 noAI step 时同样执行 _cliAction,校验 throw →
202
+ // completeStep 不推进(step 保持 pending)。分支对齐 stage.js 的 noAI 自动执行。
203
+ const _isNoAIStep = currentStepDef.noAI === true || currentStep?.noAI === true
204
+ if (_isNoAIStep) {
205
+ const _cliAction = currentStepDef._cliAction || currentStep?._cliAction
206
+ console.log(`⚙️ Step ${currentIdx + 1}/${steps.length}: ${steps[currentIdx].name}(noAI,--done 路径执行 CLI 校验)`)
207
+ if (_cliAction === 'scanPreflight') {
208
+ await executeScanPreflight(cwd, platformOpts, stageData.scanProfile || computeScanProfile(cwd, platformOpts))
209
+ } else if (_cliAction === 'scanPostcheck') {
210
+ await executeScanPostcheck(cwd, platformOpts, stageData.scanProfile || computeScanProfile(cwd, platformOpts))
211
+ } else if (_cliAction === 'planPostcheck') {
212
+ await runPlanPostcheckLib({ cwd, specRoot: platformOpts?.specRoot, resolveChangeDir, progress })
213
+ }
214
+ }
215
+
160
216
  steps[currentIdx].status = 'completed'
161
217
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
162
218
  if (outputText) {
@@ -452,8 +508,14 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
452
508
  if (!existsSync(planPath) || !existsSync(runIdFile)) {
453
509
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
454
510
  }
455
- const executeRunId = readFileSync(runIdFile, 'utf8').trim()
456
- const { readReview } = await import('../task-review.js')
511
+ const c = readFileSync(runIdFile, 'utf8').trim()
512
+ const { readReview, isValidExecuteRunId } = await import('../task-review.js')
513
+ // marker 是 agent 可写内容:格式校验防注入/穿越,非法视为缺失(跳过 autoCheck,不误勾)
514
+ if (c && !isValidExecuteRunId(c)) {
515
+ console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),跳过 review 自动勾选`)
516
+ return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
517
+ }
518
+ const executeRunId = c
457
519
  // plan.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
458
520
  // 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
459
521
  // (后到者覆盖先到者);写入用 writeAtomicSync(tmp+rename 原子),防 Windows 整文件覆盖被读半截
@@ -688,6 +750,7 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
688
750
  const prevOutput = currentStep.output || ''
689
751
  const waitRound = (currentStep.waitRound || 0) + 1
690
752
  currentStep.waitRound = waitRound
753
+ enforceWaitChoice(currentStepDef, currentStep, answer, stageName, changeName)
691
754
  currentStep.waitAnswer = answer
692
755
  currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
693
756
  currentStep.waitAnswers.push({
package/src/run/gates.js CHANGED
@@ -115,9 +115,14 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
115
115
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
116
116
  const planPath = join(specBase, 'changes', changeName, 'plan.md')
117
117
  if (!existsSync(runIdFile) || !existsSync(planPath)) return true
118
+ const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
118
119
  let executeRunId = readFileSync(runIdFile, 'utf8').trim()
120
+ // marker 是 agent 可写内容:格式校验防注入/穿越,非法视为缺失走漂移兜底重定位
121
+ if (executeRunId && !isValidExecuteRunId(executeRunId)) {
122
+ console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(executeRunId.slice(0, 60))}),改扫真实 run 目录`)
123
+ executeRunId = ''
124
+ }
119
125
  const planContent = readFileSync(planPath, 'utf8')
120
- const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks } = await import('../task-review.js')
121
126
  // marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
122
127
  // marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
123
128
  // 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
@@ -333,12 +338,18 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
333
338
  const planContent = readFileSync(planPath, 'utf8')
334
339
  const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
335
340
 
336
- // execute run id:从变更专属标记文件读取
341
+ // execute run id:从变更专属标记文件读取(agent 可写内容,格式校验防注入/穿越)
337
342
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
338
343
  let executeRunId = ''
339
344
  try {
340
345
  if (existsSync(runIdFile)) {
341
- executeRunId = readFileSync(runIdFile, 'utf8').trim()
346
+ const c = readFileSync(runIdFile, 'utf8').trim()
347
+ const { isValidExecuteRunId } = await import('../task-review.js')
348
+ if (c && !isValidExecuteRunId(c)) {
349
+ console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失回退扫描`)
350
+ } else {
351
+ executeRunId = c
352
+ }
342
353
  }
343
354
  } catch {}
344
355
  if (!executeRunId) {
package/src/run/prompt.js CHANGED
@@ -24,7 +24,7 @@ import { stageRegistry } from '../stages/index.js'
24
24
  import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE } from './shared.js'
25
25
  import { renderStageContract } from '../stage-contract-spec.js'
26
26
  import { parseModuleMapSimple } from '../modules.js'
27
- import { REVIEW_SCHEMA_VERSION } from '../task-review.js'
27
+ import { REVIEW_SCHEMA_VERSION, isValidExecuteRunId } from '../task-review.js'
28
28
 
29
29
  /**
30
30
  * 从 _module-map.yaml 读取模块上下文索引
@@ -428,7 +428,14 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
428
428
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
429
429
  try {
430
430
  if (existsSync(runIdFile)) {
431
- runId = readFileSync(runIdFile, 'utf8').trim()
431
+ const c = readFileSync(runIdFile, 'utf8').trim()
432
+ // marker 是 agent 可写内容,直接注入 prompt + 拼进 gate 读 review.json 的路径——
433
+ // 格式校验同时防提示词注入与路径穿越(对齐 stage-review marker 的前缀校验范式)
434
+ if (c && !isValidExecuteRunId(c)) {
435
+ console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失重新生成`)
436
+ } else {
437
+ runId = c
438
+ }
432
439
  }
433
440
  } catch {}
434
441
  if (!runId) {
package/src/run/stage.js CHANGED
@@ -89,15 +89,20 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
89
89
  // ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
90
90
  let currentExecuteRunId = null
91
91
  if (stageName === 'execute') {
92
- const { generateExecuteRunId } = await import('../task-review.js')
92
+ const { generateExecuteRunId, isValidExecuteRunId } = await import('../task-review.js')
93
93
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
94
94
  const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
95
95
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
96
96
  mkdirSync(runtimeRoot, { recursive: true })
97
- // 优先读取已有的变更专属标记文件
97
+ // 优先读取已有的变更专属标记文件(agent 可写内容,格式校验防注入/穿越,非法视为缺失重生成)
98
98
  try {
99
99
  if (existsSync(runIdFile)) {
100
- currentExecuteRunId = readFileSync(runIdFile, 'utf8').trim()
100
+ const c = readFileSync(runIdFile, 'utf8').trim()
101
+ if (c && !isValidExecuteRunId(c)) {
102
+ console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),重新生成`)
103
+ } else {
104
+ currentExecuteRunId = c
105
+ }
101
106
  }
102
107
  } catch {}
103
108
  if (!currentExecuteRunId) {
package/src/setup.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
- import { execSync } from 'child_process';
3
+ import { execSync, execFileSync } from 'child_process';
4
4
  import chalk from 'chalk';
5
5
  import ora from 'ora';
6
6
  import { checkbox, input } from '@inquirer/prompts';
@@ -13,7 +13,9 @@ const MCP_TOOLS = [
13
13
  name: 'Context7',
14
14
  description: '查询最新库文档和 API 参考',
15
15
  command: 'npx',
16
- args: ['-y', '@upstash/context7-mcp@latest'],
16
+ // 版本锁定(sec-c):MCP server 常驻且有系统访问能力,@latest = trust-on-future-publish,
17
+ // 上游被攻破会直接进用户 AI 工具执行环境。升级走 setup 重跑。
18
+ args: ['-y', '@upstash/context7-mcp@4.0.2'],
17
19
  url: 'https://github.com/upstash/context7-mcp',
18
20
  },
19
21
  {
@@ -21,7 +23,7 @@ const MCP_TOOLS = [
21
23
  name: 'Chrome DevTools MCP',
22
24
  description: '浏览器自动化,支持 E2E 测试(需 Chrome 已运行)',
23
25
  command: 'npx',
24
- args: ['chrome-devtools-mcp@latest'],
26
+ args: ['chrome-devtools-mcp@1.7.0'],
25
27
  url: 'https://github.com/ChromeDevTools/chrome-devtools-mcp',
26
28
  },
27
29
  {
@@ -29,7 +31,7 @@ const MCP_TOOLS = [
29
31
  name: 'Agent Browser (Vercel)',
30
32
  description: 'Rust 原生浏览器 CLI,token 消耗极低,50+ 命令覆盖导航/表单/截图/网络',
31
33
  command: 'npx',
32
- args: ['@anthropic-ai/agent-browser@latest'],
34
+ args: ['agent-browser@0.34.0'],
33
35
  url: 'https://github.com/vercel-labs/agent-browser',
34
36
  },
35
37
  {
@@ -37,7 +39,7 @@ const MCP_TOOLS = [
37
39
  name: 'PinchTab',
38
40
  description: '12MB Go 二进制,零依赖,accessibility tree 极省 token,有 MCP 支持',
39
41
  command: 'npx',
40
- args: ['pinchtab-mcp@latest'],
42
+ args: ['pinchtab-mcp@1.4.1'],
41
43
  url: 'https://github.com/pinchtab/pinchtab',
42
44
  },
43
45
  ];
@@ -50,7 +52,7 @@ const DB_MCP_TOOLS = [
50
52
  name: 'PostgreSQL',
51
53
  description: '只读访问 PostgreSQL 数据库',
52
54
  command: 'npx',
53
- args: ['-y', '@modelcontextprotocol/server-postgres'],
55
+ args: ['-y', '@modelcontextprotocol/server-postgres@0.6.2'],
54
56
  envTemplate: { POSTGRES_CONNECTION_STRING: 'postgresql://user:password@localhost:5432/dbname' },
55
57
  url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/postgres',
56
58
  },
@@ -59,7 +61,7 @@ const DB_MCP_TOOLS = [
59
61
  name: 'SQLite',
60
62
  description: '访问 SQLite 数据库文件',
61
63
  command: 'npx',
62
- args: ['-y', '@modelcontextprotocol/server-sqlite'],
64
+ args: ['-y', 'mcp-server-sqlite@0.0.2'],
63
65
  envTemplate: { SQLITE_DB_PATH: './data.db' },
64
66
  url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite',
65
67
  },
@@ -68,7 +70,8 @@ const DB_MCP_TOOLS = [
68
70
  name: 'MySQL / MariaDB',
69
71
  description: '访问 MySQL / MariaDB 数据库',
70
72
  command: 'npx',
71
- args: ['-y', '@nicobailon/mysql-mcp-server'],
73
+ // 原 @nicobailon/mysql-mcp-server 已 404(包下架),换存在的 mysql-mcp-server(dpflucas,read-only)
74
+ args: ['-y', 'mysql-mcp-server@0.1.3'],
72
75
  envTemplate: { MYSQL_HOST: 'localhost', MYSQL_PORT: '3306', MYSQL_USER: 'root', MYSQL_PASSWORD: '', MYSQL_DATABASE: '' },
73
76
  url: 'https://github.com/modelcontextprotocol/servers',
74
77
  },
@@ -77,7 +80,7 @@ const DB_MCP_TOOLS = [
77
80
  name: 'Redis',
78
81
  description: '访问 Redis 数据库,查看缓存和键值数据',
79
82
  command: 'npx',
80
- args: ['-y', '@modelcontextprotocol/server-redis'],
83
+ args: ['-y', '@modelcontextprotocol/server-redis@2025.4.25'],
81
84
  envTemplate: { REDIS_URL: 'redis://localhost:6379' },
82
85
  url: 'https://github.com/modelcontextprotocol/servers/tree/main/src/redis',
83
86
  },
@@ -353,6 +356,21 @@ export async function cmdSetup(dir, options = {}) {
353
356
  }
354
357
  writeMcpConfig(dir, path, config);
355
358
  spinner.succeed(`${tool} MCP 完成 (${allMcp.length} 个工具)`);
359
+ // 凭据提交风险提示(sec-c):.claude/mcp.json / .cursor/mcp.json 是项目共享文件、通常随 git
360
+ // 提交,而 DB MCP 的 env 含明文连接串/密码。检测 git 跟踪状态,已跟踪/未忽略时显式警告。
361
+ if (selectedDb.length > 0) {
362
+ try {
363
+ execFileSync('git', ['-C', dir, 'ls-files', '--error-unmatch', path], { stdio: 'pipe' });
364
+ console.warn(chalk.yellow(` ⚠️ ${path} 已被 git 跟踪——其中数据库连接串/密码会随提交进入仓库,建议加入 .gitignore 或改用环境变量`));
365
+ } catch {
366
+ // 未被跟踪:再查是否被 ignore;都不满足(未忽略未跟踪)时提示防止将来误提交
367
+ try {
368
+ execFileSync('git', ['-C', dir, 'check-ignore', '-q', path], { stdio: 'pipe' });
369
+ } catch {
370
+ console.warn(chalk.yellow(` ⚠️ ${path} 含数据库连接串/密码且未被 .gitignore 忽略——提交前请先加入 .gitignore 或改用环境变量`));
371
+ }
372
+ }
373
+ }
356
374
  }
357
375
  }
358
376
 
@@ -20,10 +20,13 @@
20
20
  */
21
21
 
22
22
  import { readMcpConfig } from './config.js';
23
+ import { getVersion } from '../version.js';
23
24
 
24
25
  const DEFAULT_TIMEOUT_MS = 10_000;
25
26
  // MCP streamable HTTP 协议版本(task 指定 2025-11-25)
26
27
  const MCP_PROTOCOL_VERSION = '2025-11-25';
28
+ // 单次 RPC 返回哨兵:server 回 400 Missing session(session 未建立/过期)→ _sendRpc 重连重试
29
+ const SESSION_EXPIRED = Symbol('SESSION_EXPIRED');
27
30
 
28
31
  export class SillyHubMcpClient {
29
32
  /**
@@ -38,9 +41,15 @@ export class SillyHubMcpClient {
38
41
  const cfg = readMcpConfig(workCwd);
39
42
  const u = url !== undefined ? url : (cfg?.url ?? '');
40
43
  const t = token !== undefined ? token : (cfg?.token ?? '');
41
- // 去掉 url 尾部斜杠,便于后续稳定拼 /mcp/(跨平台:URL 一律用正斜杠,不用 path.join)
44
+ // 去 url 尾部斜杠,便于后续稳定拼 /mcp/(跨平台:URL 一律用正斜杠,不用 path.join)
42
45
  this._url = typeof u === 'string' ? u.replace(/\/+$/, '') : '';
43
46
  this._token = typeof t === 'string' ? t : '';
47
+ // 安全提示(sec-e 缓解):url 来自 local.yaml(agent 可写),非 https 时 Bearer token 明文上线。
48
+ // 一次性 warn(构造时)不刷屏;本机 http://localhost 调试场景 warn 可接受。
49
+ if (this._url && !/^https:\/\//i.test(this._url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(this._url)) {
50
+ console.warn(`[sillyhub-mcp] mcp.url 非 https(${this._url.slice(0, 60)}),Bearer token 将明文传输——请确认为受控环境`);
51
+ }
52
+ this._token = typeof t === 'string' ? t : '';
44
53
  this._timeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0
45
54
  ? timeoutMs
46
55
  : DEFAULT_TIMEOUT_MS;
@@ -49,6 +58,9 @@ export class SillyHubMcpClient {
49
58
  // 端点必须带尾斜杠
50
59
  this._endpoint = this._url ? `${this._url}/mcp/` : '';
51
60
  this._rpcId = 0;
61
+ // MCP streamable HTTP session 状态:惰性 initialize 后才有(见 _ensureSession)
62
+ this._sessionId = null;
63
+ this._sessionPromise = null;
52
64
  }
53
65
 
54
66
  // ── 内部辅助 ──
@@ -69,7 +81,15 @@ export class SillyHubMcpClient {
69
81
  }
70
82
 
71
83
  /**
72
- * 发 JSON-RPC 请求(fetch/SSE/鉴权骨架,tools/call 与 tools/list 共用)。best-effort 不抛。
84
+ * 发 JSON-RPC 请求(fetch/SSE/鉴权/session 骨架,tools/call 与 tools/list 共用)。best-effort 不抛。
85
+ *
86
+ * MCP streamable HTTP(2025-11-25)要求先 initialize 拿 session,后续请求带
87
+ * Mcp-Session-Id header,否则 server 回 -32600 Missing session ID(2026-08-14 实测:
88
+ * SillyHub FastMCP v1.29 streamable_http_app 强制 session;此前本 client 直接发
89
+ * tools/call 无 session → 一律 400,probe 误判 daemon-unreachable)。本方法:
90
+ * 1. 惰性 _ensureSession(并发安全,仅 initialize 一次);
91
+ * 2. _rpcOnce 发单次请求;
92
+ * 3. 遇 session 过期(400 Missing session)→ 重置重连重试一次。
73
93
  *
74
94
  * task-11 抽出:原 _callTool 的网络骨架;method/params 泛化——tools/call 带 {name,arguments},
75
95
  * tools/list 带空 params。warn 文案用 label 作 tag(tools/call 传 toolName 保文案不变,
@@ -88,14 +108,43 @@ export class SillyHubMcpClient {
88
108
  return null;
89
109
  }
90
110
 
91
- this._rpcId += 1;
92
- const id = this._rpcId;
111
+ // 惰性 initialize + 过期重连(最多 2 次:首次 + 重试)
112
+ for (let attempt = 0; attempt < 2; attempt++) {
113
+ await this._ensureSession(quiet);
114
+ if (!this._sessionId) {
115
+ // initialize 失败(网络/非 2xx/无 session)→ best-effort 降级,不再发业务请求
116
+ if (!quiet) console.warn(`[sillyhub-mcp] ${tag} initialize 失败,跳过`);
117
+ return null;
118
+ }
119
+ this._rpcId += 1;
120
+ const result = await this._rpcOnce(method, params, { quiet, tag, id: this._rpcId });
121
+ if (result !== SESSION_EXPIRED) return result;
122
+ // session 过期(400 Missing session)→ 重置重连重试一次
123
+ if (!quiet) console.warn(`[sillyhub-mcp] ${tag} session 过期,重连重试`);
124
+ this._sessionId = null;
125
+ this._sessionPromise = null;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * 发单次 JSON-RPC 请求(不管理 session)。返回 rpc.result;失败返回 null;
132
+ * 400 Missing session ID → 返回 SESSION_EXPIRED 哨兵(调用方 _sendRpc 据此重连重试)。
133
+ */
134
+ async _rpcOnce(method, params, { quiet, tag, id }) {
93
135
  const body = {
94
136
  jsonrpc: '2.0',
95
137
  id,
96
138
  method,
97
139
  params,
98
140
  };
141
+ const headers = {
142
+ Authorization: `Bearer ${this._token}`,
143
+ 'Content-Type': 'application/json',
144
+ Accept: 'application/json, text/event-stream',
145
+ 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
146
+ };
147
+ if (this._sessionId) headers['Mcp-Session-Id'] = this._sessionId;
99
148
 
100
149
  const controller = new AbortController();
101
150
  const timer = setTimeout(() => controller.abort(), this._timeoutMs);
@@ -103,12 +152,7 @@ export class SillyHubMcpClient {
103
152
  try {
104
153
  res = await fetch(this._endpoint, {
105
154
  method: 'POST',
106
- headers: {
107
- Authorization: `Bearer ${this._token}`,
108
- 'Content-Type': 'application/json',
109
- Accept: 'application/json, text/event-stream',
110
- 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
111
- },
155
+ headers,
112
156
  body: JSON.stringify(body),
113
157
  signal: controller.signal,
114
158
  });
@@ -127,11 +171,13 @@ export class SillyHubMcpClient {
127
171
  }
128
172
 
129
173
  if (!res.ok) {
130
- if (!quiet) {
131
- let detail = '';
132
- try { detail = await res.text(); } catch { /* best effort */ }
133
- console.warn(`[sillyhub-mcp] ${tag} → HTTP ${res.status} ${String(detail).slice(0, 200)}`);
174
+ let detail = '';
175
+ try { detail = await res.text(); } catch { /* best effort */ }
176
+ // session 未建立/过期:FastMCP 回 HTTP 400 + body 含 "Missing session ID"
177
+ if (res.status === 400 && detail.includes('Missing session')) {
178
+ return SESSION_EXPIRED;
134
179
  }
180
+ if (!quiet) console.warn(`[sillyhub-mcp] ${tag} → HTTP ${res.status} ${String(detail).slice(0, 200)}`);
135
181
  return null;
136
182
  }
137
183
 
@@ -167,6 +213,95 @@ export class SillyHubMcpClient {
167
213
  return rpc.result || null;
168
214
  }
169
215
 
216
+ /**
217
+ * 确保已建立 MCP session(惰性 initialize,并发安全)。已初始化直接返回 true;
218
+ * 初始化进行中则复用同一 promise。失败返回 false 且不留半成品状态。
219
+ * @param {boolean} [quiet] - 静默初始化失败警告(探测路径默认用)
220
+ * @returns {Promise<boolean>}
221
+ */
222
+ async _ensureSession(quiet = false) {
223
+ if (this._sessionId) return true;
224
+ if (this._sessionPromise) return this._sessionPromise;
225
+ this._sessionPromise = this._initialize(quiet).finally(() => { this._sessionPromise = null; });
226
+ return this._sessionPromise;
227
+ }
228
+
229
+ /**
230
+ * MCP initialize 握手:POST initialize → 从响应 header(或 body _meta)读 mcp-session-id。
231
+ * 成功存 this._sessionId;失败返回 false(best-effort,warn 不抛)。
232
+ *
233
+ * 注:协议要求 initialize 后发 notifications/initialized,本客户端只做探测
234
+ * (tools/list / 只读 tool),实测 FastMCP 不强制该通知即可用(2026-08-14 验证),
235
+ * 为省一次 RPC 不发;若未来 server 强校验再补。
236
+ * @returns {Promise<boolean>}
237
+ */
238
+ async _initialize(quiet = false) {
239
+ this._rpcId += 1;
240
+ const id = this._rpcId;
241
+ const body = {
242
+ jsonrpc: '2.0',
243
+ id,
244
+ method: 'initialize',
245
+ params: {
246
+ protocolVersion: MCP_PROTOCOL_VERSION,
247
+ capabilities: {},
248
+ // MCP 2025-11-25 协议要求 clientInfo 必含 name + version,缺 version FastMCP
249
+ // 回 -32602 Invalid request parameters(2026-08-14 实测锁定)
250
+ clientInfo: { name: 'sillyspec-cli', version: getVersion() },
251
+ },
252
+ };
253
+ const controller = new AbortController();
254
+ const timer = setTimeout(() => controller.abort(), this._timeoutMs);
255
+ let res;
256
+ try {
257
+ res = await fetch(this._endpoint, {
258
+ method: 'POST',
259
+ headers: {
260
+ Authorization: `Bearer ${this._token}`,
261
+ 'Content-Type': 'application/json',
262
+ Accept: 'application/json, text/event-stream',
263
+ 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
264
+ },
265
+ body: JSON.stringify(body),
266
+ signal: controller.signal,
267
+ });
268
+ } catch (err) {
269
+ if (!quiet) {
270
+ const msg = err && err.message ? err.message : String(err);
271
+ console.warn(`[sillyhub-mcp] initialize 请求失败: ${msg}`);
272
+ }
273
+ return false;
274
+ } finally {
275
+ clearTimeout(timer);
276
+ }
277
+
278
+ if (!res.ok) {
279
+ if (!quiet) console.warn(`[sillyhub-mcp] initialize → HTTP ${res.status}`);
280
+ return false;
281
+ }
282
+
283
+ // 优先取 header mcp-session-id(FastMCP 实测返回);缺失再查 body _meta.sessionId。
284
+ // ⚠️ 无论 header 是否有 session,都必须读完 body(消费 SSE 流)——FastMCP 在
285
+ // initialize 响应流未消费完时 session 未就绪,后续 tools/call 回 -32602
286
+ // "Invalid request parameters"(2026-08-14 实测:node 只读 header 不读 body 必现,
287
+ // python 读完 body 则过)。
288
+ let sid = res.headers.get('mcp-session-id');
289
+ let text = '';
290
+ try { text = await res.text(); } catch { /* best effort */ }
291
+ if (!sid && text) {
292
+ const rpc = this._parseSseResponse(text, id);
293
+ if (rpc && rpc.result && rpc.result._meta && typeof rpc.result._meta.sessionId === 'string') {
294
+ sid = rpc.result._meta.sessionId;
295
+ }
296
+ }
297
+ if (!sid) {
298
+ if (!quiet) console.warn('[sillyhub-mcp] initialize 响应无 mcp-session-id');
299
+ return false;
300
+ }
301
+ this._sessionId = sid;
302
+ return true;
303
+ }
304
+
170
305
  /**
171
306
  * 解析 SSE 响应:取含 JSON-RPC result/error 的事件;优先 id 精确匹配,否则取首个。
172
307
  * 事件间空行分隔;事件内 data: 行可多行,拼装时用 \n 连接。
@@ -66,6 +66,7 @@ export const definition = {
66
66
  maxWaitRounds: 5,
67
67
  waitReason: '等待用户回答需求问题',
68
68
  waitOptions: ['回答见--answer', '信息够了,进入设计'],
69
+ waitFreeAnswer: true, // 开放回答型:--answer 内容为自由文本(需求澄清回答),非封闭单选,豁免 enforceWaitChoice
69
70
  prompt: `分析需求,必要时追问,然后设计方案。
70
71
 
71
72
  ### 操作
@@ -79,6 +79,7 @@ export const definition = {
79
79
  maxWaitRounds: 8,
80
80
  waitReason: '等待用户回答需求问题或澄清',
81
81
  waitOptions: ['回答见--answer', '信息够了,进入方案讨论'],
82
+ waitFreeAnswer: true, // 开放回答型:--answer 内容为自由文本(需求澄清回答),非封闭单选,豁免 enforceWaitChoice
82
83
  prompt: `通过对话探索需求细节、分析原型(如有)、判断拆分/批量、并对歧义点做需求澄清 Grill。本步骤合并了原有的对话探索、原型分析、需求范围评估、需求澄清 Grill 四个环节——按下方 A→D 顺序按需执行。提问遵守「一次只问一个」(见 D 节追问策略):必须问的问题逐个问,能查代码/文档解决的不问;不要把多个歧义打包成一屏清单一次抛给用户(用户记不住、答不全,反而漏掉关键约束);也不要为凑数把单个问题拖成多轮。
83
84
 
84
85
  ### A. 原型/设计图分析(如用户提供)
package/src/sync.js CHANGED
@@ -27,9 +27,10 @@ const LOCAL_YAML = '.sillyspec/local.yaml';
27
27
  const CHANGES_DIR = '.sillyspec/changes';
28
28
  const REQUEST_TIMEOUT_MS = 10_000;
29
29
 
30
- // 未连接平台是本地独立用户的合法默认状态。sync / syncDocuments / checkApproval 由 run 流程
31
- // 在后台 best-effort 触发(每步完成、execute 阶段启动),未连接时默认静默跳过——不每步催连
32
- // 平台制造噪音(本地用户根本不需要平台)。需要排查同步行为时设 SILLYSPEC_DEBUG_SYNC=1。
30
+ // 未连接平台是本地独立用户的合法默认状态。sync / checkApproval 由 run 流程在后台 best-effort
31
+ // 触发(每步完成、execute 阶段启动);syncDocuments 仅由手动 `sillyspec platform sync-docs`
32
+ // 命令触发(index.js sync-docs 分支),run 流程不自动推文档。未连接时默认静默跳过——不每步
33
+ // 催连平台制造噪音(本地用户根本不需要平台)。需要排查同步行为时设 SILLYSPEC_DEBUG_SYNC=1。
33
34
  function debugLog(msg) {
34
35
  if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg)
35
36
  }
@@ -136,6 +137,13 @@ function writeLocalYamlRaw(cwd, text) {
136
137
  }
137
138
 
138
139
  function parseSimpleYaml(content) {
140
+ // 剥 YAML 双/单引号包裹(connect 写侧 yamlStr 加引号防 # : 注入,读侧对称剥;无引号值零影响)
141
+ const unquote = (v) => {
142
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
143
+ return v.slice(1, -1);
144
+ }
145
+ return v;
146
+ };
139
147
  const result = {};
140
148
  let currentSection = null;
141
149
  for (const line of content.split('\n')) {
@@ -147,7 +155,7 @@ function parseSimpleYaml(content) {
147
155
  const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
148
156
  if (m) {
149
157
  const key = m[1];
150
- const val = m[2].trim();
158
+ const val = unquote(m[2].trim());
151
159
  if (val) {
152
160
  result[key] = val;
153
161
  currentSection = null;
@@ -159,7 +167,7 @@ function parseSimpleYaml(content) {
159
167
  } else if (currentSection) {
160
168
  const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
161
169
  if (m && result[currentSection] && typeof result[currentSection] === 'object') {
162
- result[currentSection][m[1]] = m[2].trim();
170
+ result[currentSection][m[1]] = unquote(m[2].trim());
163
171
  }
164
172
  }
165
173
  }
@@ -296,19 +304,24 @@ export class SyncManager {
296
304
 
297
305
  // 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
298
306
  // (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
307
+ // YAML 值加引号:token/url 含 # : 或首尾空白时裸写会破坏段结构或注入额外键(shpsync_ token
308
+ // 当前不含这些字符,防御性包裹零成本;parseSimpleYaml 读侧剥引号兼容)
309
+ const yamlStr = (v) => JSON.stringify(String(v));
299
310
  const platformEntries = [
300
- ` url: ${normalizedUrl}`,
301
- ` token: ${effectiveToken}`,
302
- ` last_connected: ${new Date().toISOString()}`,
311
+ ` url: ${yamlStr(normalizedUrl)}`,
312
+ ` token: ${yamlStr(effectiveToken)}`,
313
+ ` last_connected: ${yamlStr(new Date().toISOString())}`,
303
314
  ];
304
315
  if (resolvedUser) {
305
- platformEntries.push(` user: ${resolvedUser}`);
316
+ platformEntries.push(` user: ${yamlStr(resolvedUser)}`);
306
317
  }
307
318
  let text = readLocalYamlRaw(this.cwd);
308
319
  text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
309
- // mcp 段同源假设(design §7.4):复用 platform 的 url/token;用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
320
+ // mcp 段同源假设(design §7.4):url 复用 platform 的 url;token 用原始 user 级 token
321
+ // (非换发的 effectiveToken——MCP 派发需 user 级权限,platform 段才是最小权限的 shpsync_)。
322
+ // 用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
310
323
  if (findTopLevelSectionRange(text, 'mcp') === null) {
311
- text = replaceTopLevelSection(text, 'mcp', ` url: ${normalizedUrl}\n token: ${token}`);
324
+ text = replaceTopLevelSection(text, 'mcp', ` url: ${yamlStr(normalizedUrl)}\n token: ${yamlStr(token)}`);
312
325
  }
313
326
  writeLocalYamlRaw(this.cwd, text);
314
327
  }
@@ -390,8 +403,8 @@ export class SyncManager {
390
403
  if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs; // base_ts 乐观锁(NULL=首次同步不设,平台接受首次 push)
391
404
  headers['X-SillySpec-Pushed-At'] = pushedAt; // 平台存 last_pushed_at,作下次其他用户 push 的 base_ts 比对基准
392
405
 
393
- // POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)
394
- const syncUrl = `${platform.url}/api/changes/${changeName}/progress`;
406
+ // POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)。changeName encode 对齐 pull(:673)
407
+ const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
395
408
  const res = await fetchJsonWithStatus(syncUrl, {
396
409
  method: 'POST',
397
410
  headers,
@@ -475,7 +488,7 @@ export class SyncManager {
475
488
  return { synced: 0, errors: [...errors, '无可用文档'] };
476
489
  }
477
490
 
478
- const docUrl = `${platform.url}/api/changes/${changeName}/documents`;
491
+ const docUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/documents`;
479
492
  const result = await fetchJson(docUrl, {
480
493
  method: 'POST',
481
494
  headers: {
@@ -510,7 +523,7 @@ export class SyncManager {
510
523
  return { status: 'pending', reason: '未指定变更名称' };
511
524
  }
512
525
 
513
- const approvalUrl = `${platform.url}/api/changes/${changeName}/approval`;
526
+ const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
514
527
  const result = await fetchJson(approvalUrl, {
515
528
  headers: { Authorization: `Bearer ${platform.token}` },
516
529
  });
@@ -924,6 +937,11 @@ export async function collectStatus(cwd) {
924
937
  return new SyncManager(cwd).collectStatus();
925
938
  }
926
939
 
940
+ // 未决冲突文件列表便捷导出(index.js platform resolve 参数解析/报错兜底用,只读)
941
+ export function listConflictFiles(cwd) {
942
+ return new SyncManager(cwd).listConflictFiles();
943
+ }
944
+
927
945
  // TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
928
946
  // 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
929
947
  /**
@@ -118,10 +118,16 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
118
118
  report: '⚠️ plan.md 未解析出任何 task-NN 条目(plan 可能未按规范写 checkbox 列表)。' }
119
119
  }
120
120
 
121
- // runId 解析:marker → 扫描最新目录
121
+ // runId 解析:marker → 扫描最新目录(marker 是 agent 可写内容,格式校验防注入/穿越)
122
122
  let runId = null
123
123
  const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
124
- try { if (existsSync(marker)) runId = readFileSync(marker, 'utf8').trim() } catch {}
124
+ try {
125
+ if (existsSync(marker)) {
126
+ const c = readFileSync(marker, 'utf8').trim()
127
+ if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
128
+ if (isValidExecuteRunId(c)) runId = c
129
+ }
130
+ } catch {}
125
131
  if (!runId) {
126
132
  try {
127
133
  const runsDir = join(runtimeRoot, 'execute-runs')
@@ -657,6 +663,18 @@ export function generateExecuteRunId() {
657
663
  return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
658
664
  }
659
665
 
666
+ /**
667
+ * execute run id 格式校验:exec-YYYY-MM-DD-HHMMSS。
668
+ * marker 文件是 agent 可写内容,读回后既注入 prompt({EXECUTE_RUN_ID})又拼进
669
+ * join(runtimeRoot, 'execute-runs', runId) 路径——格式校验同时防提示词注入与路径穿越
670
+ * (对齐 stage-review marker 的 review- 前缀校验范式)。
671
+ * @param {string} runId
672
+ * @returns {boolean}
673
+ */
674
+ export function isValidExecuteRunId(runId) {
675
+ return typeof runId === 'string' && /^exec-\d{4}-\d{2}-\d{2}-\d{6}$/.test(runId)
676
+ }
677
+
660
678
  /**
661
679
  * 解析最新 execute run id:优先 current-execute-run-id-<changeName> marker;
662
680
  * marker 缺失/为空则扫描 execute-runs/ 下子目录、取 mtime 最新的(尽力定位已有 run)。
@@ -699,7 +717,13 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
699
717
 
700
718
  export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
701
719
  const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
702
- try { if (existsSync(marker)) { const c = readFileSync(marker, 'utf8').trim(); if (c) return c } } catch {}
720
+ try {
721
+ if (existsSync(marker)) {
722
+ const c = readFileSync(marker, 'utf8').trim()
723
+ if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
724
+ if (isValidExecuteRunId(c)) return c
725
+ }
726
+ } catch {}
703
727
  try {
704
728
  const runsDir = join(runtimeRoot, 'execute-runs')
705
729
  if (existsSync(runsDir)) {
@@ -758,7 +782,13 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
758
782
  // exec-id:与 Task Review Gate(gates.js:269)/ autoCheckPlanFromReviews 同源
759
783
  const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
760
784
  let executeRunId = ''
761
- try { if (existsSync(runIdFile)) executeRunId = readFileSync(runIdFile, 'utf8').trim() } catch {}
785
+ try {
786
+ if (existsSync(runIdFile)) {
787
+ const c = readFileSync(runIdFile, 'utf8').trim()
788
+ if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失重新生成`)
789
+ if (isValidExecuteRunId(c)) executeRunId = c
790
+ }
791
+ } catch {}
762
792
  if (!executeRunId) {
763
793
  executeRunId = generateExecuteRunId()
764
794
  try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
@@ -915,7 +945,8 @@ export function getLatestExecuteRunId(runtimeRoot) {
915
945
  try {
916
946
  if (existsSync(markerPath)) {
917
947
  const content = readFileSync(markerPath, 'utf8').trim()
918
- if (content) return content
948
+ if (content && !isValidExecuteRunId(content)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(content.slice(0, 60))}),忽略并回退目录扫描`)
949
+ if (isValidExecuteRunId(content)) return content
919
950
  }
920
951
  } catch {}
921
952
 
@@ -15,7 +15,7 @@
15
15
  * 测试,避免 monorepo 全量测试超 gate timeout。
16
16
  */
17
17
 
18
- import { execSync } from 'child_process'
18
+ import { execSync, execFileSync } from 'child_process'
19
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
20
20
  import { join } from 'path'
21
21
  import { verifyApiParity } from './contract-matrix.js'
@@ -367,14 +367,28 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
367
367
  }
368
368
  }
369
369
 
370
+ // refSpec 来源 meta.json(agent 可写),仅放行 git ref/range 安全字符(含 HEAD~1..HEAD 类区间),
371
+ // 防经 shell 的命令/参数注入。剥空白与引号/分号/反引号/$ 等 shell 元字符。
372
+ // 注:允许 ".."/"../.."(相对路径 ref 语法上合法)不影响安全——execFileSync 数组形式下它只是
373
+ // 一个 git rev 参数,git 自会报 not a valid ref,无 shell 解释面。
374
+ function assertSafeRefSpec(refSpec) {
375
+ if (typeof refSpec !== 'string') return null
376
+ if (!/^[A-Za-z0-9._~/^-]+$/.test(refSpec)) return null
377
+ if (/~~/.test(refSpec)) return null // 连续 ~(垃圾串)
378
+ return refSpec
379
+ }
380
+
370
381
  /**
371
382
  * 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
372
383
  * git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
373
384
  */
374
385
  function runGitDiffNameOnly(cwd, refSpec) {
375
386
  try {
376
- const cmd = refSpec ? `git diff --name-only ${refSpec}` : 'git diff --name-only'
377
- const out = execSync(cmd, {
387
+ const ref = refSpec ? assertSafeRefSpec(refSpec) : null
388
+ if (refSpec && !ref) return null
389
+ const args = ['diff', '--name-only']
390
+ if (ref) args.push(ref)
391
+ const out = execFileSync('git', args, {
378
392
  cwd,
379
393
  encoding: 'utf8',
380
394
  timeout: 30 * 1000,
@@ -392,8 +406,11 @@ function runGitDiffNameOnly(cwd, refSpec) {
392
406
  */
393
407
  function runGitDiffNameStatus(cwd, refSpec) {
394
408
  try {
395
- const cmd = refSpec ? `git diff --name-status ${refSpec}` : 'git diff --name-status'
396
- return execSync(cmd, {
409
+ const ref = refSpec ? assertSafeRefSpec(refSpec) : null
410
+ if (refSpec && !ref) return null
411
+ const args = ['diff', '--name-status']
412
+ if (ref) args.push(ref)
413
+ return execFileSync('git', args, {
397
414
  cwd,
398
415
  encoding: 'utf8',
399
416
  timeout: 30 * 1000,
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, realpathSync } from 'fs';
12
12
  import { join } from 'path';
13
- import { execSync } from 'child_process';
13
+ import { execSync, execFileSync } from 'child_process';
14
14
  import { createHash } from 'crypto';
15
15
 
16
16
  const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
@@ -118,10 +118,12 @@ function tryLink(mainNodeModules, linkPath) {
118
118
  }
119
119
  try {
120
120
  if (process.platform === 'win32') {
121
- execSync(`mklink /J "${linkPath}" "${mainNodeModules}"`, { shell: 'cmd.exe', stdio: ['pipe', 'pipe', 'pipe'] });
121
+ // execFileSync 数组形式不经 shell:POSIX 双引号内 `/$() 会执行、cmd.exe 双引号内 %VAR% 仍展开,
122
+ // linkPath 含 local.yaml 模块 path,属项目内可配置值(安全收敛,与 git-helper 同范式)
123
+ execFileSync('cmd.exe', ['/c', 'mklink', '/J', linkPath, mainNodeModules], { stdio: ['pipe', 'pipe', 'pipe'] });
122
124
  return { ok: true, method: 'junction' };
123
125
  }
124
- execSync(`ln -s "${mainNodeModules}" "${linkPath}"`, { stdio: ['pipe', 'pipe', 'pipe'] });
126
+ execFileSync('ln', ['-s', mainNodeModules, linkPath], { stdio: ['pipe', 'pipe', 'pipe'] });
125
127
  return { ok: true, method: 'symlink' };
126
128
  } catch (e) {
127
129
  return { ok: false, error: e.message };
package/src/worktree.js CHANGED
@@ -813,7 +813,8 @@ export class WorktreeManager {
813
813
  try {
814
814
  if (process.platform === 'win32') {
815
815
  // Windows rmdir 删 junction(reparse point)不跟随目标,保护主 checkout
816
- execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
816
+ // execFileSync 数组形式:路径含引号/特殊字符时不会经 shell 解析(安全收敛,与 git-helper 同范式)
817
+ execFileSync('cmd.exe', ['/c', 'rmdir', wtNodeModules]);
817
818
  } else {
818
819
  unlinkSync(wtNodeModules);
819
820
  }
@@ -946,7 +947,8 @@ export class WorktreeManager {
946
947
  if (isLink) {
947
948
  try {
948
949
  if (process.platform === 'win32') {
949
- execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
950
+ // execFileSync 数组形式:不经 shell,防 meta.worktreePath 注入(安全收敛)
951
+ execFileSync('cmd.exe', ['/c', 'rmdir', wtNodeModules]);
950
952
  } else {
951
953
  unlinkSync(wtNodeModules);
952
954
  }