feat: implement Obsidian MCP Bundle MVP (Phase 1-3)

- Complete project setup with TypeScript, Jest, MCPB manifest
- Implement foundational infrastructure (CLI executor, logger, error handler)
- Add 9 file operation tools for User Story 1
- Full MCP protocol compliance with stdio transport
- Input validation and sanitization for security
- Comprehensive error handling with actionable messages
- Constitutional compliance: all 6 principles satisfied

MVP includes:
- obsidian_create_note, read, append, prepend, delete, move, rename, open, file_info
- Zod validation schemas for all parameters
- 30s timeout configuration with per-command overrides
- Stderr-only logging with sanitized output
- Graceful shutdown handling

Build:  0 errors, 0 vulnerabilities
Tasks: 48/167 complete (MVP milestone)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-22 11:21:38 -05:00
parent e9e0112240
commit 622b28e42c
35 changed files with 5139 additions and 35 deletions

155
src/validation/sanitizer.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* Parameter Sanitizer
* Constitutional Principle III: Local Execution Security
* Remove dangerous characters and validate inputs before CLI execution
*/
import { logger } from '../utils/logger.js';
/**
* Characters that should be removed or escaped for security
*/
const DANGEROUS_CHARS = /[;&|`$(){}[\]<>]/g;
const COMMAND_INJECTION_PATTERNS = [
/\$\(/g, // Command substitution $(...)
/`[^`]*`/g, // Command substitution `...`
/\|\|/g, // OR operator
/&&/g, // AND operator
/;/g, // Command separator
];
/**
* Sanitize a single string parameter
*/
export function sanitizeString(input: string): string {
if (typeof input !== 'string') {
logger.warn('sanitizeString received non-string input', { type: typeof input });
return String(input);
}
// Remove null bytes
let sanitized = input.replace(/\0/g, '');
// Check for command injection patterns
for (const pattern of COMMAND_INJECTION_PATTERNS) {
if (pattern.test(sanitized)) {
logger.warn('Potential command injection detected', {
pattern: pattern.toString(),
});
sanitized = sanitized.replace(pattern, '');
}
}
// Remove dangerous characters
sanitized = sanitized.replace(DANGEROUS_CHARS, '');
// Trim whitespace
sanitized = sanitized.trim();
return sanitized;
}
/**
* Sanitize a file path
*/
export function sanitizePath(path: string): string {
if (typeof path !== 'string') {
return '';
}
// Remove null bytes
let sanitized = path.replace(/\0/g, '');
// Remove path traversal attempts
sanitized = sanitized.replace(/\.\./g, '');
// Remove leading/trailing slashes (relative paths only)
sanitized = sanitized.replace(/^\/+|\/+$/g, '');
// Remove dangerous characters but allow path separators
sanitized = sanitized.replace(/[;&|`$(){}[\]<>]/g, '');
return sanitized;
}
/**
* Sanitize a tag (must start with #)
*/
export function sanitizeTag(tag: string): string {
if (typeof tag !== 'string') {
return '#';
}
let sanitized = tag.trim();
// Ensure tag starts with #
if (!sanitized.startsWith('#')) {
sanitized = '#' + sanitized;
}
// Allow only alphanumeric, hyphens, underscores, and forward slashes
sanitized = sanitized.replace(/[^#a-zA-Z0-9_/-]/g, '');
// Remove empty tags
if (sanitized === '#') {
return '';
}
return sanitized;
}
/**
* Sanitize object parameters recursively
*/
export function sanitizeParameters(params: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string') {
// Special handling for different parameter types
if (key === 'tag' || key.startsWith('tag')) {
sanitized[key] = sanitizeTag(value);
} else if (key === 'path' || key.endsWith('Path') || key.endsWith('path')) {
sanitized[key] = sanitizePath(value);
} else {
sanitized[key] = sanitizeString(value);
}
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Recursively sanitize nested objects
sanitized[key] = sanitizeParameters(value as Record<string, unknown>);
} else if (Array.isArray(value)) {
// Sanitize array elements
sanitized[key] = value.map((item) =>
typeof item === 'string' ? sanitizeString(item) : item
);
} else {
// Keep other types as-is (numbers, booleans, null)
sanitized[key] = value;
}
}
return sanitized;
}
/**
* Validate that parameters don't contain shell metacharacters
*/
export function containsDangerousCharacters(input: string): boolean {
if (typeof input !== 'string') {
return false;
}
// Check for dangerous characters
if (DANGEROUS_CHARS.test(input)) {
return true;
}
// Check for command injection patterns
for (const pattern of COMMAND_INJECTION_PATTERNS) {
if (pattern.test(input)) {
return true;
}
}
return false;
}