UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

293 lines (292 loc) 12 kB
/** * PathGuard — 文件写入路径安全守卫(双层防护) * * 防止 AutoSnippet 在项目目录之外 或 项目内非法位置 创建文件。 * BiliDemo/data 事件的根因:process.cwd() 解析到非预期目录,DB/日志等写操作 * 逃逸到用户项目外,创建了脏数据。 * * 双层防护: * Layer 1 — assertSafe(path): * 边界检查,拦截写到 projectRoot 外的操作 * Layer 2 — assertProjectWriteSafe(path): * 项目内作用域检查,仅允许写入以下前缀: * .autosnippet/ — 运行时 DB、记忆、对话、信号快照 * {kbDir}/ — 知识库(recipes、candidates、skills、guard 文件) * .cursor/ — Cursor IDE 集成 * .vscode/ — VSCode 集成 * .github/ — Copilot instructions * .gitignore — 追加忽略规则 * 项目内其他位置(如 data/、src/ 等)一律拦截 * * 设计: * - 单例模式,通过 configure() 绑定 projectRoot * - 新建文件/目录前调用 assertProjectWriteSafe() 校验 * - 修改已有文件前调用 assertSafe() 校验(不限制项目内位置) * - 允许白名单目录(Xcode snippets、全局缓存等) * - 错误不静默:越界写操作抛出 PathGuardError */ import path from 'node:path'; import { isAutoSnippetDevRepo, isExcludedProject } from './isOwnDevRepo.js'; import { DEFAULT_KNOWLEDGE_BASE_DIR, detectKnowledgeBaseDir, } from './ProjectMarkers.js'; export class PathGuardError extends Error { projectRoot; targetPath; /** * @param targetPath 被拦截的目标路径 * @param projectRoot 当前项目根目录 * @param [reason] 拦截原因 */ constructor(targetPath, projectRoot, reason) { const msg = reason ? `[PathGuard] ${reason}: "${targetPath}"` : `[PathGuard] 写入路径越界: "${targetPath}" 不在允许范围内。`; super(msg + `\n projectRoot: ${projectRoot}` + `\n 提示: 检查 process.cwd() 或 projectRoot 配置是否正确`); this.name = 'PathGuardError'; this.targetPath = targetPath; this.projectRoot = projectRoot; } } /** * 项目内允许 AutoSnippet 创建新文件/目录的前缀 * 注意:这是相对于 projectRoot 的前缀列表 */ const PROJECT_WRITE_SCOPE_PREFIXES = [ '.autosnippet', // 运行时 DB、记忆、对话、信号快照 '.cursor', // Cursor IDE 集成 '.vscode', // VSCode 集成 '.github', // Copilot instructions ]; /** 项目根目录下允许直接写入的文件(非目录前缀匹配) */ const PROJECT_ROOT_WRITABLE_FILES = ['.gitignore', '.env']; class PathGuard { targetPath; /** 项目根目录(绝对路径) */ #projectRoot = null; /** AutoSnippet 包自身根目录 */ #packageRoot = null; /** 额外允许的绝对路径前缀 */ #allowList = new Set(); /** 知识库目录名(如 'AutoSnippet') */ #knowledgeBaseDir = null; /** 是否已配置 */ #configured = false; /** projectRoot 是否是 AutoSnippet 自身的开发仓库 */ #isDevRepo = false; /** projectRoot 是否是应排除的项目(开发仓库、生态项目等) */ #isExcludedProject = false; /** 排除原因 */ #excludeReason = ''; /** * 配置 PathGuard(每个进程执行一次) * @param opts.projectRoot 用户项目根目录(绝对路径) * @param [opts.packageRoot] AutoSnippet 包自身根目录 * @param [opts.knowledgeBaseDir='AutoSnippet'] 知识库目录名 * @param [opts.extraAllowPaths] 额外允许的路径前缀 */ configure({ projectRoot, packageRoot, knowledgeBaseDir, extraAllowPaths = [], }) { if (!projectRoot || !path.isAbsolute(projectRoot)) { throw new Error(`[PathGuard] projectRoot 必须是绝对路径,收到: "${projectRoot}"`); } this.#projectRoot = path.resolve(projectRoot); this.#packageRoot = packageRoot ? path.resolve(packageRoot) : null; this.#knowledgeBaseDir = knowledgeBaseDir || null; // 延迟解析 this.#isDevRepo = isAutoSnippetDevRepo(this.#projectRoot); const exclusion = isExcludedProject(this.#projectRoot); this.#isExcludedProject = exclusion.excluded; this.#excludeReason = exclusion.reason; // 默认白名单:全局缓存 + 平台 Snippets 目录 const HOME = process.env.HOME || process.env.USERPROFILE || ''; if (HOME) { this.#allowList.add(path.join(HOME, '.autosnippet', 'cache')); this.#allowList.add(path.join(HOME, '.autosnippet', 'snippets')); if (process.platform === 'darwin') { this.#allowList.add(path.join(HOME, 'Library/Developer/Xcode/UserData/CodeSnippets')); } } // 用户自定义白名单 for (const p of extraAllowPaths) { if (path.isAbsolute(p)) { this.#allowList.add(path.resolve(p)); } } this.#configured = true; } /** 是否已配置 */ get configured() { return this.#configured; } /** 当前 projectRoot */ get projectRoot() { return this.#projectRoot; } /** * 设置知识库目录名(可在 configure 之后延迟设置) * @param dirName 如 'AutoSnippet'、'Knowledge' 等 */ setKnowledgeBaseDir(dirName) { if (dirName && typeof dirName === 'string') { this.#knowledgeBaseDir = dirName; } } /** * Layer 1: 断言路径在允许的边界范围内 * 用于修改已有文件的场景(如 XcodeIntegration 插入 header、SpmHelper 修改 Package.swift) * @param targetPath 要写入的绝对路径 * @throws {PathGuardError} */ assertSafe(targetPath) { if (!this.#configured) { return; } if (!targetPath || typeof targetPath !== 'string') { throw new PathGuardError(String(targetPath), this.#projectRoot); } const resolved = path.resolve(targetPath); // 1. 项目目录内 — 允许 if (this.#isUnder(resolved, this.#projectRoot)) { return; } // 2. AutoSnippet 包自身目录内(logs/ 等)— 允许 if (this.#packageRoot && this.#isUnder(resolved, this.#packageRoot)) { return; } // 3. 白名单目录 — 允许 for (const allowed of this.#allowList) { if (this.#isUnder(resolved, allowed)) { return; } } // 越界 throw new PathGuardError(resolved, this.#projectRoot); } /** * Layer 2: 断言路径在项目内允许的写入作用域中 * 用于创建新目录/新文件的场景(如 mkdirSync、writeFileSync 创建新文件) * 比 assertSafe() 更严格:即使在 projectRoot 内,也只允许写入特定前缀 * @param targetPath 要创建的绝对路径 * @throws {PathGuardError} */ assertProjectWriteSafe(targetPath) { if (!this.#configured) { return; } // 先做边界检查 this.assertSafe(targetPath); const resolved = path.resolve(targetPath); // 如果不在 projectRoot 内(在白名单/packageRoot 中),跳过项目内检查 if (!this.#isUnder(resolved, this.#projectRoot)) { return; } // 计算相对于 projectRoot 的路径 const relative = path.relative(this.#projectRoot, resolved); const firstSegment = relative.split(path.sep)[0]; // ── 排除项目保护 ────────────────────────────────── // 如果 projectRoot 是排除项目(开发仓库、生态项目等), // 禁止写入 .autosnippet/ 和知识库目录 if (this.#isExcludedProject) { if (firstSegment === '.autosnippet') { throw new PathGuardError(resolved, this.#projectRoot, `排除项目保护 (${this.#excludeReason}): 禁止创建 .autosnippet/ 运行时数据`); } const kbDir = this.#resolveKnowledgeBaseDir(); if (kbDir && firstSegment === kbDir) { throw new PathGuardError(resolved, this.#projectRoot, `排除项目保护 (${this.#excludeReason}): 禁止创建 ${kbDir}/ 知识库数据`); } // 排除项目内仍允许写入 .cursor/.vscode/.github 等 IDE 配置 for (const prefix of PROJECT_WRITE_SCOPE_PREFIXES) { if (prefix !== '.autosnippet' && firstSegment === prefix) { return; } } if (PROJECT_ROOT_WRITABLE_FILES.includes(relative)) { return; } throw new PathGuardError(resolved, this.#projectRoot, `排除项目保护 (${this.#excludeReason}): "${relative}" 不在允许范围内`); } // 检查是否在允许的前缀中 for (const prefix of PROJECT_WRITE_SCOPE_PREFIXES) { if (firstSegment === prefix) { return; } } // 检查知识库目录(动态解析) const kbDir = this.#resolveKnowledgeBaseDir(); if (kbDir && firstSegment === kbDir) { return; } // 检查根目录可写文件(如 .gitignore) if (PROJECT_ROOT_WRITABLE_FILES.includes(relative)) { return; } // 不在允许的写入范围内 throw new PathGuardError(resolved, this.#projectRoot, `项目内写入范围受限: "${relative}" 不在允许的目录中(允许: ${[...PROJECT_WRITE_SCOPE_PREFIXES, kbDir || 'AutoSnippet'].join(', ')})`); } /** 安全检查(不抛错,返回 boolean) */ isSafe(targetPath) { try { this.assertSafe(targetPath); return true; } catch { return false; } } /** 项目内写入范围检查(不抛错,返回 boolean) */ isProjectWriteSafe(targetPath) { try { this.assertProjectWriteSafe(targetPath); return true; } catch { return false; } } /** * 将相对路径安全地解析到 projectRoot 下 * 替代 path.resolve(relativePath)(后者基于 cwd,不安全) * @returns 绝对路径 */ resolveProjectPath(relativePath) { if (!this.#configured || !this.#projectRoot) { // 未配置时 fallback 到 cwd(向后兼容) return path.resolve(relativePath); } const resolved = path.resolve(this.#projectRoot, relativePath); this.assertSafe(resolved); return resolved; } /** 重置状态(仅用于测试) */ _reset() { this.#projectRoot = null; this.#packageRoot = null; this.#allowList.clear(); this.#knowledgeBaseDir = null; this.#configured = false; this.#isDevRepo = false; } /** resolved 是否在 base 目录下 */ #isUnder(resolved, base) { return resolved === base || resolved.startsWith(base + path.sep); } /** * 解析知识库目录名 * 优先使用 configure 阶段传入的值,否则委托 ProjectMarkers 统一探测 */ #resolveKnowledgeBaseDir() { if (this.#knowledgeBaseDir) { return this.#knowledgeBaseDir; } // 运行时探测: 委托 ProjectMarkers 统一逻辑 if (this.#projectRoot) { const detected = detectKnowledgeBaseDir(this.#projectRoot); this.#knowledgeBaseDir = detected; return detected; } // 默认 return DEFAULT_KNOWLEDGE_BASE_DIR; } } // 单例 — 整个进程共享 const pathGuard = new PathGuard(); export default pathGuard;