/** * Local Filesystem Source Adapter (SMI-591, SMI-4287, SMI-4319, SMI-4320) * * Scans local directories for SKILL.md files. * Useful for local development and testing. * * SMI-4287 hardening: * - Symlink targets are resolved via `fs.realpath` and checked against the * adapter's `rootDir`. Targets outside root are skipped with a * `symlink-escape` warning (unless `allowSymlinksOutsideRoot` is `true`). * - Permission (EACCES/EPERM), not-found (ENOENT), and loop (ELOOP) errors * are surfaced as `AdapterError` entries on `SourceSearchResult.warnings` * instead of throwing, so siblings continue to be scanned. * - All `fs.*` calls route through the typed `safeFs` helpers; the historic * bare `try/catch` for EACCES in `scanDirectory` is removed. * * SMI-4319 hardening: * - `runScan` allocates a fresh `visitedRealpaths: Set` per * invocation so mutually-recursive / self-looping directory symlinks are * detected and skipped with a `loop` warning instead of silently wasting * `maxDepth` traversals. * * SMI-4320 hardening: * - `resolveSkillPath` is now async and routes through `resolveSafeRealpath` * (byte-wise `startsWith(rootReal + sep)` on realpath outputs — no * platform lowercasing). Direct-access methods (`getRepository`, * `fetchSkillContent`, `skillExists`) inherit containment instead of * relying solely on lexical `validatePath`. This closes the scan-to-fetch * TOCTOU window where an indexed-then-swapped symlink previously escaped * containment. `allowSymlinksOutsideRoot` is honoured at every realpath * callsite. Residual TOCTOU between `resolveSkillPath` and the subsequent * `fs.readFile` is documented; closing it requires fd-based I/O and is * tracked as a separate follow-up. */ import { BaseSourceAdapter } from './BaseSourceAdapter.js'; import type { SourceConfig, SourceLocation, SourceRepository, SourceSearchOptions, SourceSearchResult, SkillContent, SourceHealth } from './types.js'; /** * Configuration for local filesystem adapter */ export interface LocalFilesystemConfig extends SourceConfig { /** Root directory to scan for skills */ rootDir: string; /** Maximum directory depth to search (default: 5) */ maxDepth?: number; /** Patterns to exclude (glob-style) */ excludePatterns?: string[]; /** Whether to follow symlinks (default: false) */ followSymlinks?: boolean; /** * Allow symlinks whose target resolves outside `rootDir` (SMI-4287). * * Default `false`: symlinks pointing outside the scan root are skipped and * a `symlink-escape` entry is added to `SourceSearchResult.warnings`. This * prevents an attacker with write access to `rootDir` from exfiltrating * content from arbitrary locations on the filesystem (GitHub #600). * * Set to `true` only if you trust every symlink inside `rootDir` (e.g. * monorepo layouts that intentionally point at sibling packages). The * caller accepts the security tradeoff. * * Note: this flag has no effect when `followSymlinks` is `false` — symlinks * are never traversed in that case. */ allowSymlinksOutsideRoot?: boolean; } /** * Local Filesystem Source Adapter * * Scans local directories to discover and index skills. * * @example * ```typescript * const adapter = new LocalFilesystemAdapter({ * id: 'local-skills', * name: 'Local Skills', * type: 'local', * baseUrl: 'file://', * enabled: true, * rootDir: '/home/user/.claude/skills' * }) * * await adapter.initialize() * const result = await adapter.search({}) * for (const warning of result.warnings ?? []) { * console.warn(`[${warning.code}] ${warning.message}`) * } * ``` */ export declare class LocalFilesystemAdapter extends BaseSourceAdapter { private readonly rootDir; private readonly maxDepth; private readonly excludePatterns; private readonly followSymlinks; private readonly allowSymlinksOutsideRoot; private discoveredSkills; /** * Warnings accumulated during the most recent scan. Consumed and cleared * by `search()` so each caller sees only the warnings from that call's * underlying scan. */ private scanWarnings; constructor(config: LocalFilesystemConfig); /** * Initialize by scanning the filesystem */ protected doInitialize(): Promise; /** * Check if root directory exists and is accessible. * * SMI-4287: routes `fs.stat(rootDir)` through `safeFs` so the raw Node * error is translated to a typed `AdapterError` message. */ protected doHealthCheck(): Promise>; /** * Search for skills in the scanned directories. * * SMI-4287: `warnings` collects non-fatal `AdapterError` entries from the * scan (symlink escapes, permission denials, loops). An empty array is * returned as `undefined` to keep the field strictly optional. */ search(options?: SourceSearchOptions): Promise; /** * Get repository info for a skill location. * * SMI-4287: `fs.stat` is routed through `safeFs`; permission errors are * converted to typed Error messages instead of raw Node throws. */ getRepository(location: SourceLocation): Promise; /** * Fetch skill content from local file. * * SMI-4287: both `fs.readFile` and `fs.stat` route through `safeFs`, so * permission errors (EACCES/EPERM) raise typed Errors with path context * instead of raw Node errors. */ fetchSkillContent(location: SourceLocation): Promise; /** * Check if skill exists at location */ skillExists(location: SourceLocation): Promise; /** * Rescan the filesystem for new skills. * * Returns the count of discovered skills. Warnings from the rescan are * available via the next call to `search()`. */ rescan(): Promise; /** * Get count of discovered skills */ get skillCount(): number; /** * Run the recursive scan starting at `rootDir`. Delegates to the extracted * `scanDirectoryRecursive` helper (see `LocalFilesystemAdapter.scan.ts`). * * SMI-4319: allocates a fresh `visitedRealpaths` set per invocation so * back-to-back scans don't share state. Sibling directories within a * single scan share the set (they're in the same call tree), so * cross-linked loops (A↔B) are caught even when the loop isn't on the * descent path from `rootDir`. */ private runScan; /** * Check if a path/name should be excluded (SMI-722, SMI-726) * Uses centralized safe pattern matching to prevent RegExp injection */ private isExcluded; /** * Resolve a skill location to a full filesystem path * (SMI-720, SMI-726, SMI-4287, SMI-4320). * * Two-stage containment: (1) lexical `validatePath` fast-fails * `../`-style traversal (SMI-720 contract — callers assert the * "Path traversal detected" message), then (2) `resolveSafeRealpath` * enforces realpath byte-wise containment so symlinks can't escape * `rootDir` even when the lexical path is clean. Honours the SMI-4287 * `allowSymlinksOutsideRoot` opt-in. * * Not-found behaviour: realpath ENOENT falls back to the lexically * resolved path so downstream `stat` / `readFile` produce the canonical * caller-visible error. TOCTOU caveat: the window between this resolve * and the caller's subsequent read remains open; closing it requires * fd-based I/O and is tracked separately. */ private resolveSkillPath; /** * Convert discovered skill to SourceRepository. * * Returns both the repository and any warnings encountered while reading * the file (typically permission errors on SKILL.md after discovery). */ private skillToRepository; /** * Generate a deterministic ID from a path */ private generateId; /** * Generate SHA hash for content */ private generateSha; } /** * Factory function for creating local filesystem adapters */ export declare function createLocalFilesystemAdapter(config: LocalFilesystemConfig): LocalFilesystemAdapter; //# sourceMappingURL=LocalFilesystemAdapter.d.ts.map