path-access-control.tsā¢4.86 kB
import { existsSync, readFileSync } from 'fs';
import { resolve, join } from 'path';
import { minimatch } from 'minimatch';
export interface PathPattern {
pattern: string;
isNegation: boolean;
originalLine: string;
}
/**
* PathAccessControl manages filesystem access restrictions via an allowlist file
* Supports .gitignore-style patterns with negation
*/
export class PathAccessControl {
private patterns: PathPattern[] = [];
private allowFilePath: string;
constructor(allowFilePath?: string) {
// Default to ~/.mcp-clipboard/paths.allow
const defaultPath = join(
process.env.HOME || process.env.USERPROFILE || '.',
'.mcp-clipboard',
'paths.allow'
);
this.allowFilePath = allowFilePath || defaultPath;
this.loadPatterns();
}
/**
* Check if a target path is allowed by the current patterns
* @param targetPath - Path to check (will be resolved to absolute)
* @returns true if allowed, false if denied
*/
isPathAllowed(targetPath: string): boolean {
const absolutePath = resolve(targetPath);
// If no patterns loaded or file doesn't exist, default to allow all
if (this.patterns.length === 0) {
return true;
}
// Evaluate patterns top-to-bottom, later patterns override earlier ones
let allowed = false;
for (const patternObj of this.patterns) {
const matches = this.matchesPattern(absolutePath, patternObj.pattern);
if (matches) {
// If it's a negation pattern and matches, deny access
// If it's a positive pattern and matches, allow access
allowed = !patternObj.isNegation;
}
}
return allowed;
}
/**
* Reload patterns from the allowlist file
*/
reload(): void {
this.loadPatterns();
}
/**
* Get the current patterns (for testing/debugging)
*/
getPatterns(): PathPattern[] {
return [...this.patterns];
}
/**
* Get the allowlist file path
*/
getAllowFilePath(): string {
return this.allowFilePath;
}
/**
* Load and parse patterns from the allowlist file
*/
private loadPatterns(): void {
this.patterns = [];
// If file doesn't exist, default to allow all with wildcard pattern
if (!existsSync(this.allowFilePath)) {
this.patterns.push({
pattern: '**/*',
isNegation: false,
originalLine: '*',
});
return;
}
try {
const content = readFileSync(this.allowFilePath, 'utf-8');
const lines = content.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (trimmed === '' || trimmed.startsWith('#')) {
continue;
}
// Parse pattern
const isNegation = trimmed.startsWith('!');
const pattern = isNegation ? trimmed.slice(1).trim() : trimmed;
if (pattern === '') {
continue; // Skip if pattern is empty after removing negation
}
// Normalize the pattern
const normalizedPattern = this.normalizePattern(pattern);
this.patterns.push({
pattern: normalizedPattern,
isNegation,
originalLine: line,
});
}
// If file exists but has no valid patterns, default to allow all
if (this.patterns.length === 0) {
this.patterns.push({
pattern: '**/*',
isNegation: false,
originalLine: '*',
});
}
} catch (error) {
// If file read fails, log warning and default to allow all
console.warn(
`Warning: Failed to read allowlist file ${this.allowFilePath}: ${error}. Defaulting to allow all.`
);
this.patterns.push({
pattern: '**/*',
isNegation: false,
originalLine: '*',
});
}
}
/**
* Normalize a pattern for matching
* Converts simple wildcards to proper glob patterns
*/
private normalizePattern(pattern: string): string {
// If it's just a wildcard, convert to match all
if (pattern === '*') {
return '**/*';
}
// If pattern is absolute, use it as-is
if (pattern.startsWith('/')) {
return pattern;
}
// For relative patterns, make them match anywhere in the tree
if (!pattern.startsWith('**/')) {
return `**/${pattern}`;
}
return pattern;
}
/**
* Check if a path matches a glob pattern
* @param path - Absolute path to check
* @param pattern - Glob pattern to match against
*/
private matchesPattern(path: string, pattern: string): boolean {
// Handle absolute patterns
if (pattern.startsWith('/')) {
return minimatch(path, pattern, { dot: true });
}
// For relative patterns (starting with **/ or similar), check if path matches
return minimatch(path, pattern, { dot: true, matchBase: true });
}
}