diff --git a/src/embeddings.ts b/src/embeddings.ts index 1ccdf9d..cd33175 100644 --- a/src/embeddings.ts +++ b/src/embeddings.ts @@ -1,4 +1,4 @@ -import type { PluginConfig } from './types.js'; +import type { PluginConfig } from './types.js'; import { requestRemoteEmbedding } from './embedding-provider-client.js'; import { averageEmbeddings, @@ -58,21 +58,17 @@ export class EmbeddingGenerator { return results; } - private async generateChunk(chunk: EmbeddingChunk): Promise { + private generateChunk(chunk: EmbeddingChunk): Promise { if (this.provider === 'hash') { - return hashEmbedding(chunk.content, this.dimensions); - } - try { - return await requestRemoteEmbedding({ - provider: this.provider, - model: this.config.embeddingModel, - dimensions: this.dimensions, - apiKey: this.config.embeddingApiKey, - apiUrl: this.config.embeddingApiUrl, - }, chunk.content); - } catch { - return hashEmbedding(chunk.content, this.dimensions); + return Promise.resolve(hashEmbedding(chunk.content, this.dimensions)); } + return requestRemoteEmbedding({ + provider: this.provider, + model: this.config.embeddingModel, + dimensions: this.dimensions, + apiKey: this.config.embeddingApiKey, + apiUrl: this.config.embeddingApiUrl, + }, chunk.content); } } diff --git a/src/redactor.ts b/src/redactor.ts index fb2378a..f81b0f4 100644 --- a/src/redactor.ts +++ b/src/redactor.ts @@ -140,6 +140,10 @@ export class Redactor { } try { + const trimmed = text.trim(); + if (this.config.categories.path !== 'off' && looksLikeStandalonePath(trimmed)) { + return this.redactPath(text); + } return this.redactUnsafe(text); } catch { // Fail-closed: redact everything we safely can @@ -418,6 +422,16 @@ function isAbsolutePath(value: string): boolean { return pathStyle(value) === 'windows' ? win32.isAbsolute(value) : posix.isAbsolute(value); } +function looksLikeStandalonePath(value: string): boolean { + if (!isAbsolutePath(value) || /\s--?[A-Za-z0-9]/u.test(value)) return false; + const absolutePathStarts = value.match( + /(?:^|\s)(?:[A-Za-z]:[\\/]|\\\\|\/(?:home|Users|root|var|opt|etc|tmp|usr|mnt|srv|data|www|private|Volumes|Library|Applications)(?:\/|$))/gu, + ); + if ((absolutePathStarts?.length ?? 0) > 1) return false; + if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\')) return true; + return /^\/(?:home|Users|root|var|opt|etc|tmp|usr|mnt|srv|data|www|private|Volumes|Library|Applications)(?:\/.+)+$/u.test(value); +} + /** * Convenience function: redact a string with default config. */ diff --git a/src/wiki-export-files.ts b/src/wiki-export-files.ts index 7a7efdf..e46c2e1 100644 --- a/src/wiki-export-files.ts +++ b/src/wiki-export-files.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; -import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import { dirname, posix, win32 } from 'node:path'; import { renderLogEntry, type LogEntry, type RenderedNote } from './wiki-note-renderer.js'; import type { WikiExportManifest } from './wiki-export-types.js'; @@ -55,18 +55,40 @@ export function pruneOwnedFiles(outputDir: string, paths: string[]): number { } export function resolveOwnedPath(outputDir: string, manifestPath: string): string { - if (!manifestPath || isAbsolute(manifestPath)) { + const windowsRoot = isWindowsPath(outputDir) + || (!posix.isAbsolute(outputDir) && process.platform === 'win32'); + const pathApi = windowsRoot ? win32 : posix; + const containsTraversal = manifestPath.split(/[\\/]/u).includes('..'); + + if ( + !manifestPath + || manifestPath.includes('\0') + || containsTraversal + || /^[A-Za-z]:/u.test(manifestPath) + || win32.isAbsolute(manifestPath) + || posix.isAbsolute(manifestPath) + ) { throw new Error(`Unsafe wiki manifest path: ${manifestPath}`); } - const root = resolve(outputDir); - const candidate = resolve(root, manifestPath); - const fromRoot = relative(root, candidate); - if (!fromRoot || fromRoot.startsWith('..') || isAbsolute(fromRoot)) { + + const root = pathApi.resolve(outputDir); + const candidate = pathApi.resolve(root, manifestPath); + const fromRoot = pathApi.relative(root, candidate); + if ( + !fromRoot + || fromRoot === '..' + || fromRoot.startsWith(`..${pathApi.sep}`) + || pathApi.isAbsolute(fromRoot) + ) { throw new Error(`Unsafe wiki manifest path: ${manifestPath}`); } return candidate; } +function isWindowsPath(value: string): boolean { + return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\'); +} + export function appendExportLog(outputDir: string, entry: LogEntry): void { const logPath = resolveOwnedPath(outputDir, 'log.md'); const existing = existsSync(logPath) ? readFileSync(logPath, 'utf8') : ''; diff --git a/test/backfill-recall-telemetry.test.ts b/test/backfill-recall-telemetry.test.ts index ce57278..b7b1f87 100644 --- a/test/backfill-recall-telemetry.test.ts +++ b/test/backfill-recall-telemetry.test.ts @@ -35,8 +35,8 @@ describe('Phase 19b — backfill and recall telemetry', () => { databaseUrl: buildTempDbUrl(BASE_DB_URL, tempDbName), databaseProvider: 'postgres', sqlitePath: '.data/csm-memory.db', - embeddingModel: 'nomic-embed-text', - embeddingApiUrl: process.env.OLLAMA_URL ?? 'http://localhost:11434', + embeddingModel: 'test-hash', + embeddingDimensions: 768, } as PluginConfig; let db: Database; diff --git a/test/hybrid-search.test.ts b/test/hybrid-search.test.ts index 86d173b..53d5fb7 100644 --- a/test/hybrid-search.test.ts +++ b/test/hybrid-search.test.ts @@ -60,8 +60,8 @@ async function cleanupDatabase( databaseUrl: databaseUrl(databaseName), databaseProvider: 'postgres', sqlitePath: '.data/csm-memory.db', - embeddingModel: 'nomic-embed-text', - embeddingApiUrl: process.env.OLLAMA_URL ?? 'http://localhost:11434', + embeddingModel: 'test-hash', + embeddingDimensions: 768, } as PluginConfig; let db: Database | undefined; let embeddings: EmbeddingGenerator;