autosnippet 2.6.0 → 2.7.1

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.
Files changed (78) hide show
  1. package/README.md +137 -65
  2. package/bin/api-server.js +5 -0
  3. package/bin/cli.js +6 -1
  4. package/dashboard/dist/assets/{icons-rnn04CvH.js → icons-B_Xg4B-s.js} +148 -88
  5. package/dashboard/dist/assets/index-BjfUm8p9.js +197 -0
  6. package/dashboard/dist/assets/index-CkIih2CC.css +1 -0
  7. package/dashboard/dist/assets/{react-markdown-CWxUbOf4.js → react-markdown-BA6FB2NP.js} +1 -1
  8. package/dashboard/dist/assets/{syntax-highlighter-CJ2drQQb.js → syntax-highlighter-CVLHn9O5.js} +1 -1
  9. package/dashboard/dist/assets/{vendor-f83ah6cm.js → vendor-BotF760a.js} +61 -61
  10. package/dashboard/dist/index.html +6 -6
  11. package/lib/bootstrap.js +18 -1
  12. package/lib/cli/SetupService.js +86 -8
  13. package/lib/cli/UpgradeService.js +139 -2
  14. package/lib/core/ast/ProjectGraph.js +599 -0
  15. package/lib/core/gateway/GatewayActionRegistry.js +2 -2
  16. package/lib/domain/recipe/Recipe.js +3 -0
  17. package/lib/external/ai/AiProvider.js +83 -20
  18. package/lib/external/ai/providers/ClaudeProvider.js +208 -0
  19. package/lib/external/ai/providers/GoogleGeminiProvider.js +247 -1
  20. package/lib/external/ai/providers/OpenAiProvider.js +141 -0
  21. package/lib/external/mcp/McpServer.js +6 -1
  22. package/lib/external/mcp/handlers/bootstrap/pipeline/dimension-context.js +216 -0
  23. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +657 -0
  24. package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +160 -0
  25. package/lib/external/mcp/handlers/bootstrap/skills.js +225 -0
  26. package/lib/external/mcp/handlers/bootstrap.js +159 -1634
  27. package/lib/external/mcp/handlers/browse.js +1 -1
  28. package/lib/external/mcp/handlers/candidate.js +1 -33
  29. package/lib/external/mcp/handlers/skill.js +58 -17
  30. package/lib/external/mcp/tools.js +4 -3
  31. package/lib/http/middleware/requestLogger.js +23 -4
  32. package/lib/http/routes/ai.js +158 -2
  33. package/lib/http/routes/auth.js +3 -2
  34. package/lib/http/routes/candidates.js +49 -25
  35. package/lib/http/routes/commands.js +0 -8
  36. package/lib/http/routes/guardRules.js +1 -16
  37. package/lib/http/routes/recipes.js +4 -17
  38. package/lib/http/routes/search.js +11 -19
  39. package/lib/http/routes/skills.js +2 -0
  40. package/lib/http/routes/snippets.js +0 -33
  41. package/lib/http/routes/spm.js +37 -63
  42. package/lib/http/utils/routeHelpers.js +31 -0
  43. package/lib/infrastructure/config/Paths.js +12 -0
  44. package/lib/infrastructure/database/DatabaseConnection.js +6 -1
  45. package/lib/infrastructure/logging/Logger.js +86 -3
  46. package/lib/infrastructure/realtime/RealtimeService.js +2 -5
  47. package/lib/infrastructure/vector/JsonVectorAdapter.js +26 -1
  48. package/lib/injection/ServiceContainer.js +55 -2
  49. package/lib/service/bootstrap/BootstrapTaskManager.js +400 -0
  50. package/lib/service/candidate/CandidateFileWriter.js +72 -27
  51. package/lib/service/candidate/CandidateService.js +156 -10
  52. package/lib/service/chat/AnalystAgent.js +245 -0
  53. package/lib/service/chat/CandidateGuardrail.js +134 -0
  54. package/lib/service/chat/ChatAgent.js +1055 -167
  55. package/lib/service/chat/ContextWindow.js +730 -0
  56. package/lib/service/chat/ConversationStore.js +3 -0
  57. package/lib/service/chat/HandoffProtocol.js +181 -0
  58. package/lib/service/chat/Memory.js +3 -0
  59. package/lib/service/chat/ProducerAgent.js +293 -0
  60. package/lib/service/chat/ToolRegistry.js +149 -5
  61. package/lib/service/chat/tools.js +1404 -61
  62. package/lib/service/guard/ExclusionManager.js +2 -0
  63. package/lib/service/guard/RuleLearner.js +2 -0
  64. package/lib/service/quality/FeedbackCollector.js +2 -0
  65. package/lib/service/recipe/RecipeFileWriter.js +16 -1
  66. package/lib/service/recipe/RecipeStatsTracker.js +2 -0
  67. package/lib/service/skills/SignalCollector.js +33 -6
  68. package/lib/service/skills/SkillAdvisor.js +2 -1
  69. package/lib/service/skills/SkillHooks.js +13 -5
  70. package/lib/service/spm/SpmService.js +2 -2
  71. package/lib/shared/PathGuard.js +314 -0
  72. package/package.json +1 -1
  73. package/resources/native-ui/combined-window.swift +494 -0
  74. package/templates/copilot-instructions.md +20 -3
  75. package/templates/cursor-rules/autosnippet-conventions.mdc +21 -4
  76. package/templates/cursor-rules/autosnippet-skills.mdc +45 -0
  77. package/dashboard/dist/assets/index-BBKa3Dgi.js +0 -195
  78. package/dashboard/dist/assets/index-DLsECfzW.css +0 -1
@@ -7,6 +7,7 @@
7
7
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
8
8
  import { join, dirname, relative } from 'node:path';
9
9
  import Logger from '../../infrastructure/logging/Logger.js';
10
+ import pathGuard from '../../shared/PathGuard.js';
10
11
 
11
12
  export class ExclusionManager {
12
13
  #exclusionsPath;
@@ -15,6 +16,7 @@ export class ExclusionManager {
15
16
  constructor(projectRoot, options = {}) {
16
17
  const kbDir = options.knowledgeBaseDir || 'AutoSnippet';
17
18
  this.#exclusionsPath = join(projectRoot, kbDir, 'guard-exclusions.json');
19
+ pathGuard.assertProjectWriteSafe(this.#exclusionsPath);
18
20
  // 迁移旧路径
19
21
  this.#migrateOldPath(projectRoot, options.internalDir || '.autosnippet');
20
22
  this.#data = this.#load();
@@ -7,6 +7,7 @@
7
7
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
8
8
  import { join, dirname } from 'node:path';
9
9
  import Logger from '../../infrastructure/logging/Logger.js';
10
+ import pathGuard from '../../shared/PathGuard.js';
10
11
 
11
12
  const PROBLEMATIC_THRESHOLD = { falsePositiveRate: 0.3, minTriggers: 5 };
12
13
 
@@ -17,6 +18,7 @@ export class RuleLearner {
17
18
  constructor(projectRoot, options = {}) {
18
19
  const kbDir = options.knowledgeBaseDir || 'AutoSnippet';
19
20
  this.#learnerPath = join(projectRoot, kbDir, 'guard-learner.json');
21
+ pathGuard.assertProjectWriteSafe(this.#learnerPath);
20
22
  this.#migrateOldPath(projectRoot, options.internalDir || '.autosnippet');
21
23
  this.#data = this.#load();
22
24
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
8
8
  import { join, dirname } from 'node:path';
9
+ import pathGuard from '../../shared/PathGuard.js';
9
10
 
10
11
  export class FeedbackCollector {
11
12
  #feedbackPath;
@@ -15,6 +16,7 @@ export class FeedbackCollector {
15
16
  constructor(projectRoot, options = {}) {
16
17
  const kbDir = options.knowledgeBaseDir || 'AutoSnippet';
17
18
  this.#feedbackPath = join(projectRoot, kbDir, 'feedback.json');
19
+ pathGuard.assertProjectWriteSafe(this.#feedbackPath);
18
20
  this.#maxEvents = options.maxEvents || 1000;
19
21
  this.#migrateOldPath(projectRoot, options.internalDir || '.autosnippet');
20
22
  this.#events = this.#load();
@@ -19,6 +19,7 @@ import path from 'node:path';
19
19
  import { createHash } from 'node:crypto';
20
20
  import { RECIPES_DIR } from '../../infrastructure/config/Defaults.js';
21
21
  import Logger from '../../infrastructure/logging/Logger.js';
22
+ import pathGuard from '../../shared/PathGuard.js';
22
23
 
23
24
  export class RecipeFileWriter {
24
25
  /**
@@ -160,6 +161,9 @@ export class RecipeFileWriter {
160
161
  const category = (recipe.category || 'general').toLowerCase();
161
162
  const categoryDir = path.join(this.recipesDir, category);
162
163
 
164
+ // 路径安全检查 — 阻止 category 含 ../ 导致路径逃逸
165
+ pathGuard.assertProjectWriteSafe(categoryDir);
166
+
163
167
  if (!fs.existsSync(categoryDir)) {
164
168
  fs.mkdirSync(categoryDir, { recursive: true });
165
169
  }
@@ -300,7 +304,18 @@ export class RecipeFileWriter {
300
304
  }
301
305
  if (recipe.content?.steps?.length > 0) {
302
306
  parts.push('### 使用步骤\n');
303
- recipe.content.steps.forEach((step, i) => { parts.push(`${i + 1}. ${step}`); });
307
+ recipe.content.steps.forEach((step, i) => {
308
+ if (typeof step === 'string') {
309
+ parts.push(`${i + 1}. ${step}`);
310
+ } else {
311
+ const title = step.title || '';
312
+ const desc = step.description || '';
313
+ const code = step.code || '';
314
+ const text = [title, desc].filter(Boolean).join(': ');
315
+ parts.push(`${i + 1}. ${text || '(步骤)'}`);
316
+ if (code) { parts.push(''); parts.push(`\`\`\`\n${code}\n\`\`\``); }
317
+ }
318
+ });
304
319
  parts.push('');
305
320
  }
306
321
  if (recipe.constraints?.boundaries?.length > 0) {
@@ -7,6 +7,7 @@
7
7
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
8
8
  import { join, dirname } from 'node:path';
9
9
  import Logger from '../../infrastructure/logging/Logger.js';
10
+ import pathGuard from '../../shared/PathGuard.js';
10
11
 
11
12
  const SCHEMA_VERSION = 1;
12
13
  const DEFAULT_HEAT_WEIGHTS = { guard: 1.0, human: 2.0, ai: 1.5 };
@@ -19,6 +20,7 @@ export class RecipeStatsTracker {
19
20
  constructor(projectRoot, options = {}) {
20
21
  const kbDir = options.knowledgeBaseDir || 'AutoSnippet';
21
22
  this.#statsPath = join(projectRoot, kbDir, 'recipe-stats.json');
23
+ pathGuard.assertProjectWriteSafe(this.#statsPath);
22
24
  this.#migrateOldPath(projectRoot, options.internalDir || '.autosnippet');
23
25
  this.#data = this.#load();
24
26
  }
@@ -42,6 +42,7 @@ import path from 'node:path';
42
42
  import { execSync } from 'node:child_process';
43
43
  import Logger from '../../infrastructure/logging/Logger.js';
44
44
  import { EventAggregator } from './EventAggregator.js';
45
+ import pathGuard from '../../shared/PathGuard.js';
45
46
 
46
47
  const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 小时(初始值,AI 可动态调整)
47
48
  const MIN_INTERVAL_MS = 5 * 60 * 1000; // 最短 5 分钟
@@ -76,14 +77,14 @@ export class SignalCollector {
76
77
  projectRoot,
77
78
  database = null,
78
79
  chatAgent = null,
79
- mode = 'suggest',
80
+ mode = 'auto',
80
81
  intervalMs = DEFAULT_INTERVAL_MS,
81
82
  onSuggestions = null,
82
83
  }) {
83
84
  this.#projectRoot = projectRoot;
84
85
  this.#db = database;
85
86
  this.#chatAgent = chatAgent;
86
- this.#mode = ['off', 'suggest', 'auto'].includes(mode) ? mode : 'suggest';
87
+ this.#mode = ['off', 'suggest', 'auto'].includes(mode) ? mode : 'auto';
87
88
  this.#intervalMs = Math.max(Math.min(intervalMs, MAX_INTERVAL_MS), MIN_INTERVAL_MS);
88
89
  this.#logger = Logger.getInstance();
89
90
  this.#onSuggestions = onSuggestions;
@@ -160,6 +161,16 @@ export class SignalCollector {
160
161
  getSnapshot() { return { ...this.#snapshot }; }
161
162
  getMode() { return this.#mode; }
162
163
 
164
+ /** 从 pendingSuggestions 中移除已创建的 Skill */
165
+ removePendingSuggestion(name) {
166
+ if (!this.#snapshot.pendingSuggestions?.length) return;
167
+ this.#snapshot.pendingSuggestions = this.#snapshot.pendingSuggestions.filter(s => s.name !== name);
168
+ if (this.#snapshot.lastResult) {
169
+ this.#snapshot.lastResult.newSuggestions = this.#snapshot.pendingSuggestions.length;
170
+ }
171
+ this.#saveSnapshot();
172
+ }
173
+
163
174
  setMode(mode) {
164
175
  if (!['off', 'suggest', 'auto'].includes(mode)) return;
165
176
  this.#mode = mode;
@@ -211,6 +222,17 @@ export class SignalCollector {
211
222
  newSuggestions: newSuggestions.length,
212
223
  aiToolCalls: toolCalls?.length || 0,
213
224
  };
225
+ // 持久化 AI 生成的建议,供前端直接读取
226
+ if (newSuggestions.length > 0) {
227
+ this.#snapshot.pendingSuggestions = newSuggestions.map(s => ({
228
+ name: s.name,
229
+ description: s.description || s.reason || '',
230
+ rationale: s.rationale || s.reason || '',
231
+ body: s.body || '',
232
+ source: s.source || 'signal-collector',
233
+ priority: s.priority || 'medium',
234
+ }));
235
+ }
214
236
 
215
237
  if (newSuggestions.length > 0) {
216
238
  for (const s of newSuggestions) {
@@ -403,14 +425,16 @@ ${JSON.stringify(signals.codeChanges, null, 2)}
403
425
 
404
426
  1. 综合分析以上 6 个维度的信号
405
427
  2. 识别重复模式、高频错误、未覆盖的操作
406
- 3. 给出 Skill 推荐建议(名称、原因、优先级、推荐 body)
407
- 4. 根据信号密度判断下次分析应间隔多久(5-1440 分钟)
408
- 5. 给出简要分析摘要
428
+ 3. **只推荐项目特有的知识模式**,不要推荐通用编程知识(如 Git 基础、语言语法等)
429
+ 4. 推荐的 Skill 应该能固化团队/项目的独有约定、架构决策或反复出现的问题解决方案
430
+ 5. 根据信号密度判断下次分析应间隔多久(5-1440 分钟)
431
+ 6. 给出简要分析摘要
432
+ 7. 如果没有发现值得推荐的项目特有模式,返回空的 suggestions 数组
409
433
 
410
434
  ## 输出格式
411
435
 
412
436
  在你的回复最后一行,输出一个 JSON 对象(不要包在 markdown code block 中):
413
- {"suggestions":[{"name":"skill-name","reason":"推荐原因","priority":"high|medium|low","body":"推荐的 Skill 内容"}],"nextIntervalMinutes":60,"summary":"一句话分析摘要"}`;
437
+ {"suggestions":[{"name":"skill-name","description":"一句话中文描述","reason":"推荐原因","priority":"high|medium|low","body":"推荐的 Skill 内容"}],"nextIntervalMinutes":60,"summary":"一句话分析摘要"}`;
414
438
  }
415
439
 
416
440
  // ═══════════════════════════════════════════════════════
@@ -473,6 +497,7 @@ ${JSON.stringify(signals.codeChanges, null, 2)}
473
497
  lastResult: data.lastResult || null,
474
498
  lastAiSummary: data.lastAiSummary || '',
475
499
  autoCreated: Array.isArray(data.autoCreated) ? data.autoCreated : [],
500
+ pendingSuggestions: Array.isArray(data.pendingSuggestions) ? data.pendingSuggestions : [],
476
501
  };
477
502
  }
478
503
  } catch { /* corrupt — reset */ }
@@ -484,6 +509,7 @@ ${JSON.stringify(signals.codeChanges, null, 2)}
484
509
  lastResult: null,
485
510
  lastAiSummary: '',
486
511
  autoCreated: [],
512
+ pendingSuggestions: [],
487
513
  };
488
514
  }
489
515
 
@@ -500,6 +526,7 @@ ${JSON.stringify(signals.codeChanges, null, 2)}
500
526
  }
501
527
 
502
528
  const dir = path.dirname(this.#snapshotPath);
529
+ pathGuard.assertProjectWriteSafe(dir);
503
530
  if (!fs.existsSync(dir)) {
504
531
  fs.mkdirSync(dir, { recursive: true });
505
532
  }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
+ import { getProjectSkillsPath } from '../../infrastructure/config/Paths.js';
20
21
 
21
22
  export class SkillAdvisor {
22
23
  #projectRoot;
@@ -299,7 +300,7 @@ export class SkillAdvisor {
299
300
  */
300
301
  #listExistingProjectSkills() {
301
302
  const names = new Set();
302
- const dir = path.join(this.#projectRoot, '.autosnippet', 'skills');
303
+ const dir = getProjectSkillsPath(this.#projectRoot);
303
304
  try {
304
305
  fs.readdirSync(dir, { withFileTypes: true })
305
306
  .filter(d => d.isDirectory())
@@ -10,18 +10,26 @@
10
10
  * - onGuardCheck(violation, ctx) → violation (可修改)
11
11
  * - onBootstrapComplete(stats, ctx) → void
12
12
  *
13
- * 加载顺序: 内置 skills/ → 项目级 .autosnippet/skills/(同名覆盖)
13
+ * 加载顺序: 内置 skills/ → 项目级 AutoSnippet/skills/(同名覆盖)
14
14
  */
15
15
 
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import Logger from '../../infrastructure/logging/Logger.js';
20
+ import { getProjectSkillsPath } from '../../infrastructure/config/Paths.js';
20
21
 
21
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
- const PROJECT_ROOT = path.resolve(__dirname, '../../..');
23
- const SKILLS_DIR = path.resolve(PROJECT_ROOT, 'skills');
24
- const PROJECT_SKILLS_DIR = path.resolve(PROJECT_ROOT, '.autosnippet', 'skills');
23
+ const SKILLS_DIR = path.resolve(__dirname, '../../../skills');
24
+
25
+ /**
26
+ * 获取项目级 Skills 目录(运行时动态解析)
27
+ * 路径: {projectRoot}/AutoSnippet/skills/
28
+ */
29
+ function _getProjectSkillsDir() {
30
+ const projectRoot = process.env.ASD_PROJECT_DIR || process.cwd();
31
+ return getProjectSkillsPath(projectRoot);
32
+ }
25
33
 
26
34
  const HOOK_NAMES = [
27
35
  'onCandidateSubmit',
@@ -48,7 +56,7 @@ export class SkillHooks {
48
56
  await this.#loadFromDir(SKILLS_DIR, loaded);
49
57
 
50
58
  // 2. 项目级 skills(覆盖同名)
51
- await this.#loadFromDir(PROJECT_SKILLS_DIR, loaded);
59
+ await this.#loadFromDir(_getProjectSkillsDir(), loaded);
52
60
 
53
61
  // 3. 注册所有钩子
54
62
  for (const [skillName, mod] of loaded) {
@@ -650,7 +650,7 @@ export class SpmService {
650
650
  const CODE_EXTS = isDirectoryTarget
651
651
  ? new Set(['.swift', '.m', '.mm', '.h', '.c', '.cpp', '.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.kt', '.go', '.rs', '.rb', '.vue', '.mjs', '.cjs'])
652
652
  : new Set(['.swift', '.m', '.h', '.c', '.cpp', '.mm']);
653
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'Pods', '.build', 'DerivedData', 'vendor', '__pycache__', '.venv', 'target']);
653
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'Pods', 'Carthage', '.build', 'DerivedData', 'vendor', '__pycache__', '.venv', 'target']);
654
654
  const MAX_FILES = 300;
655
655
 
656
656
  const files = [];
@@ -889,7 +889,7 @@ export class SpmService {
889
889
  // 非 SPM 项目:直接扫描常见源码目录(fallback)
890
890
  this.#logger.info('[SpmService] scanProject: No SPM targets, falling back to directory scan');
891
891
  const CODE_EXTS = new Set(['.swift', '.m', '.mm', '.h', '.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.kt', '.go', '.rs', '.rb', '.vue', '.mjs', '.cjs']);
892
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'Pods', '.build', 'DerivedData', 'vendor', '__pycache__', '.venv', 'target']);
892
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'Pods', 'Carthage', '.build', 'DerivedData', 'vendor', '__pycache__', '.venv', 'target']);
893
893
  const srcDirs = ['Sources', 'src', 'lib', 'app', 'pages', 'components', 'modules', 'packages'];
894
894
 
895
895
  const walkDir = (dir, targetName) => {
@@ -0,0 +1,314 @@
1
+ /**
2
+ * PathGuard — 文件写入路径安全守卫(双层防护)
3
+ *
4
+ * 防止 AutoSnippet 在项目目录之外 或 项目内非法位置 创建文件。
5
+ * BiliDemo/data 事件的根因:process.cwd() 解析到非预期目录,DB/日志等写操作
6
+ * 逃逸到用户项目外,创建了脏数据。
7
+ *
8
+ * 双层防护:
9
+ * Layer 1 — assertSafe(path):
10
+ * 边界检查,拦截写到 projectRoot 外的操作
11
+ * Layer 2 — assertProjectWriteSafe(path):
12
+ * 项目内作用域检查,仅允许写入以下前缀:
13
+ * .autosnippet/ — 运行时 DB、记忆、对话、信号快照
14
+ * {kbDir}/ — 知识库(recipes、candidates、skills、guard 文件)
15
+ * .cursor/ — Cursor IDE 集成
16
+ * .vscode/ — VSCode 集成
17
+ * .github/ — Copilot instructions
18
+ * .gitignore — 追加忽略规则
19
+ * 项目内其他位置(如 data/、src/ 等)一律拦截
20
+ *
21
+ * 设计:
22
+ * - 单例模式,通过 configure() 绑定 projectRoot
23
+ * - 新建文件/目录前调用 assertProjectWriteSafe() 校验
24
+ * - 修改已有文件前调用 assertSafe() 校验(不限制项目内位置)
25
+ * - 允许白名单目录(Xcode snippets、全局缓存等)
26
+ * - 错误不静默:越界写操作抛出 PathGuardError
27
+ */
28
+
29
+ import path from 'node:path';
30
+ import fs from 'node:fs';
31
+
32
+ export class PathGuardError extends Error {
33
+ /**
34
+ * @param {string} targetPath - 被拦截的目标路径
35
+ * @param {string} projectRoot - 当前项目根目录
36
+ * @param {string} [reason] - 拦截原因
37
+ */
38
+ constructor(targetPath, projectRoot, reason) {
39
+ const msg = reason
40
+ ? `[PathGuard] ${reason}: "${targetPath}"`
41
+ : `[PathGuard] 写入路径越界: "${targetPath}" 不在允许范围内。`;
42
+ super(
43
+ msg +
44
+ `\n projectRoot: ${projectRoot}` +
45
+ `\n 提示: 检查 process.cwd() 或 projectRoot 配置是否正确`
46
+ );
47
+ this.name = 'PathGuardError';
48
+ this.targetPath = targetPath;
49
+ this.projectRoot = projectRoot;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * 项目内允许 AutoSnippet 创建新文件/目录的前缀
55
+ * 注意:这是相对于 projectRoot 的前缀列表
56
+ */
57
+ const PROJECT_WRITE_SCOPE_PREFIXES = [
58
+ '.autosnippet', // 运行时 DB、记忆、对话、信号快照
59
+ '.cursor', // Cursor IDE 集成
60
+ '.vscode', // VSCode 集成
61
+ '.github', // Copilot instructions
62
+ ];
63
+
64
+ /**
65
+ * 项目根目录下允许直接写入的文件(非目录前缀匹配)
66
+ */
67
+ const PROJECT_ROOT_WRITABLE_FILES = [
68
+ '.gitignore',
69
+ '.env',
70
+ ];
71
+
72
+ class PathGuard {
73
+ /** @type {string|null} 项目根目录(绝对路径) */
74
+ #projectRoot = null;
75
+
76
+ /** @type {string|null} AutoSnippet 包自身根目录 */
77
+ #packageRoot = null;
78
+
79
+ /** @type {Set<string>} 额外允许的绝对路径前缀 */
80
+ #allowList = new Set();
81
+
82
+ /** @type {string|null} 知识库目录名(如 'AutoSnippet') */
83
+ #knowledgeBaseDir = null;
84
+
85
+ /** @type {boolean} 是否已配置 */
86
+ #configured = false;
87
+
88
+ constructor() {}
89
+
90
+ /**
91
+ * 配置 PathGuard(每个进程执行一次)
92
+ * @param {object} opts
93
+ * @param {string} opts.projectRoot - 用户项目根目录(绝对路径)
94
+ * @param {string} [opts.packageRoot] - AutoSnippet 包自身根目录
95
+ * @param {string} [opts.knowledgeBaseDir='AutoSnippet'] - 知识库目录名
96
+ * @param {string[]} [opts.extraAllowPaths] - 额外允许的路径前缀
97
+ */
98
+ configure({ projectRoot, packageRoot, knowledgeBaseDir, extraAllowPaths = [] }) {
99
+ if (!projectRoot || !path.isAbsolute(projectRoot)) {
100
+ throw new Error(`[PathGuard] projectRoot 必须是绝对路径,收到: "${projectRoot}"`);
101
+ }
102
+
103
+ this.#projectRoot = path.resolve(projectRoot);
104
+ this.#packageRoot = packageRoot ? path.resolve(packageRoot) : null;
105
+ this.#knowledgeBaseDir = knowledgeBaseDir || null; // 延迟解析
106
+
107
+ // 默认白名单:Xcode snippets 目录、全局缓存
108
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
109
+ if (HOME) {
110
+ this.#allowList.add(path.join(HOME, 'Library/Developer/Xcode/UserData/CodeSnippets'));
111
+ this.#allowList.add(path.join(HOME, '.autosnippet'));
112
+ }
113
+
114
+ // 用户自定义白名单
115
+ for (const p of extraAllowPaths) {
116
+ if (path.isAbsolute(p)) {
117
+ this.#allowList.add(path.resolve(p));
118
+ }
119
+ }
120
+
121
+ this.#configured = true;
122
+ }
123
+
124
+ /** 是否已配置 */
125
+ get configured() {
126
+ return this.#configured;
127
+ }
128
+
129
+ /** 当前 projectRoot */
130
+ get projectRoot() {
131
+ return this.#projectRoot;
132
+ }
133
+
134
+ /**
135
+ * 设置知识库目录名(可在 configure 之后延迟设置)
136
+ * @param {string} dirName - 如 'AutoSnippet'、'Knowledge' 等
137
+ */
138
+ setKnowledgeBaseDir(dirName) {
139
+ if (dirName && typeof dirName === 'string') {
140
+ this.#knowledgeBaseDir = dirName;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Layer 1: 断言路径在允许的边界范围内
146
+ * 用于修改已有文件的场景(如 XcodeIntegration 插入 header、SpmService 修改 Package.swift)
147
+ * @param {string} targetPath - 要写入的绝对路径
148
+ * @throws {PathGuardError}
149
+ */
150
+ assertSafe(targetPath) {
151
+ if (!this.#configured) return;
152
+
153
+ if (!targetPath || typeof targetPath !== 'string') {
154
+ throw new PathGuardError(String(targetPath), this.#projectRoot);
155
+ }
156
+
157
+ const resolved = path.resolve(targetPath);
158
+
159
+ // 1. 项目目录内 — 允许
160
+ if (this.#isUnder(resolved, this.#projectRoot)) return;
161
+
162
+ // 2. AutoSnippet 包自身目录内(logs/ 等)— 允许
163
+ if (this.#packageRoot && this.#isUnder(resolved, this.#packageRoot)) return;
164
+
165
+ // 3. 白名单目录 — 允许
166
+ for (const allowed of this.#allowList) {
167
+ if (this.#isUnder(resolved, allowed)) return;
168
+ }
169
+
170
+ // 越界
171
+ throw new PathGuardError(resolved, this.#projectRoot);
172
+ }
173
+
174
+ /**
175
+ * Layer 2: 断言路径在项目内允许的写入作用域中
176
+ * 用于创建新目录/新文件的场景(如 mkdirSync、writeFileSync 创建新文件)
177
+ * 比 assertSafe() 更严格:即使在 projectRoot 内,也只允许写入特定前缀
178
+ * @param {string} targetPath - 要创建的绝对路径
179
+ * @throws {PathGuardError}
180
+ */
181
+ assertProjectWriteSafe(targetPath) {
182
+ if (!this.#configured) return;
183
+
184
+ // 先做边界检查
185
+ this.assertSafe(targetPath);
186
+
187
+ const resolved = path.resolve(targetPath);
188
+
189
+ // 如果不在 projectRoot 内(在白名单/packageRoot 中),跳过项目内检查
190
+ if (!this.#isUnder(resolved, this.#projectRoot)) return;
191
+
192
+ // 计算相对于 projectRoot 的路径
193
+ const relative = path.relative(this.#projectRoot, resolved);
194
+ const firstSegment = relative.split(path.sep)[0];
195
+
196
+ // 检查是否在允许的前缀中
197
+ for (const prefix of PROJECT_WRITE_SCOPE_PREFIXES) {
198
+ if (firstSegment === prefix) return;
199
+ }
200
+
201
+ // 检查知识库目录(动态解析)
202
+ const kbDir = this.#resolveKnowledgeBaseDir();
203
+ if (kbDir && firstSegment === kbDir) return;
204
+
205
+ // 检查根目录可写文件(如 .gitignore)
206
+ if (PROJECT_ROOT_WRITABLE_FILES.includes(relative)) return;
207
+
208
+ // 不在允许的写入范围内
209
+ throw new PathGuardError(
210
+ resolved,
211
+ this.#projectRoot,
212
+ `项目内写入范围受限: "${relative}" 不在允许的目录中(允许: ${[...PROJECT_WRITE_SCOPE_PREFIXES, kbDir || 'AutoSnippet'].join(', ')})`,
213
+ );
214
+ }
215
+
216
+ /**
217
+ * 安全检查(不抛错,返回 boolean)
218
+ * @param {string} targetPath
219
+ * @returns {boolean}
220
+ */
221
+ isSafe(targetPath) {
222
+ try {
223
+ this.assertSafe(targetPath);
224
+ return true;
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * 项目内写入范围检查(不抛错,返回 boolean)
232
+ * @param {string} targetPath
233
+ * @returns {boolean}
234
+ */
235
+ isProjectWriteSafe(targetPath) {
236
+ try {
237
+ this.assertProjectWriteSafe(targetPath);
238
+ return true;
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * 将相对路径安全地解析到 projectRoot 下
246
+ * 替代 path.resolve(relativePath)(后者基于 cwd,不安全)
247
+ * @param {string} relativePath
248
+ * @returns {string} 绝对路径
249
+ */
250
+ resolveProjectPath(relativePath) {
251
+ if (!this.#configured || !this.#projectRoot) {
252
+ // 未配置时 fallback 到 cwd(向后兼容)
253
+ return path.resolve(relativePath);
254
+ }
255
+ const resolved = path.resolve(this.#projectRoot, relativePath);
256
+ this.assertSafe(resolved);
257
+ return resolved;
258
+ }
259
+
260
+ /**
261
+ * 重置状态(仅用于测试)
262
+ */
263
+ _reset() {
264
+ this.#projectRoot = null;
265
+ this.#packageRoot = null;
266
+ this.#allowList.clear();
267
+ this.#knowledgeBaseDir = null;
268
+ this.#configured = false;
269
+ }
270
+
271
+ /**
272
+ * resolved 是否在 base 目录下
273
+ */
274
+ #isUnder(resolved, base) {
275
+ return resolved === base || resolved.startsWith(base + path.sep);
276
+ }
277
+
278
+ /**
279
+ * 解析知识库目录名
280
+ * 优先使用 configure 阶段传入的值,否则尝试运行时探测
281
+ */
282
+ #resolveKnowledgeBaseDir() {
283
+ if (this.#knowledgeBaseDir) return this.#knowledgeBaseDir;
284
+
285
+ // 运行时探测: 查找包含 AutoSnippet.boxspec.json 的子目录
286
+ try {
287
+ const entries = fs.readdirSync(this.#projectRoot, { withFileTypes: true });
288
+ for (const e of entries) {
289
+ if (e.isDirectory() && !e.name.startsWith('.')) {
290
+ if (fs.existsSync(path.join(this.#projectRoot, e.name, 'AutoSnippet.boxspec.json'))) {
291
+ this.#knowledgeBaseDir = e.name;
292
+ return e.name;
293
+ }
294
+ }
295
+ }
296
+ } catch { /* ignore */ }
297
+
298
+ // 默认
299
+ return 'AutoSnippet';
300
+ }
301
+ }
302
+
303
+ /**
304
+ * 延迟加载 fs(避免循环依赖)
305
+ */
306
+ function await_fs() {
307
+ // eslint-disable-next-line no-eval
308
+ return eval("require('fs')");
309
+ }
310
+
311
+ // 单例 — 整个进程共享
312
+ const pathGuard = new PathGuard();
313
+
314
+ export default pathGuard;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autosnippet",
3
- "version": "2.6.0",
3
+ "version": "2.7.1",
4
4
  "description": "AutoSnippet - 连接开发者、AI 与项目知识库的工具",
5
5
  "type": "module",
6
6
  "main": "lib/bootstrap.js",