sillyspec 3.26.6 → 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.6",
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/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;
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 自身元数据)。空 → 不动文件行。
@@ -508,8 +508,14 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
508
508
  if (!existsSync(planPath) || !existsSync(runIdFile)) {
509
509
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
510
510
  }
511
- const executeRunId = readFileSync(runIdFile, 'utf8').trim()
512
- 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
513
519
  // plan.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
514
520
  // 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
515
521
  // (后到者覆盖先到者);写入用 writeAtomicSync(tmp+rename 原子),防 Windows 整文件覆盖被读半截
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 连接。
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
  }