Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Added `--tail` parameter to CLI logs command
- Added optional reason parameter to CLI cancel command
- All MCP parameters now available in CLI
- **🔒 Input Validation Limits (Issue #12)**: Security hardening for dependency system
- Maximum 100 dependencies per task to prevent DoS attacks
- Maximum 100 dependency chain depth to prevent stack overflow
- Clear error messages with current counts and limits
- Validation enforced at repository level for consistency
- **🔄 Atomic Multi-Dependency Transactions (Issue #11)**: Data consistency improvements
- New `addDependencies()` batch method with atomic all-or-nothing semantics
- Transaction rollback on any validation failure (cycle detection, duplicate, task not found)
- Prevents partial dependency state in database
- DependencyHandler updated to use atomic batch operations
- **📊 Chain Depth Calculation**: New `DependencyGraph.getMaxDepth()` algorithm
- DFS with memoization for O(V+E) complexity
- Handles diamond-shaped graphs efficiently
- Used for security validation of chain depth limits

### 🐛 Bug Fixes
- **Command Injection**: Fixed potential security vulnerabilities in git operations
- **Test Reliability**: Fixed flaky tests with proper mocking
- **Parameter Consistency**: Aligned CLI and MCP tool parameters

### 🧪 Test Coverage
- **18 new tests** for v0.3.1 security and consistency improvements:
- 11 tests for atomic batch dependency operations (rollback, validation)
- 3 tests for max dependencies per task validation (100 limit)
- 1 test for max chain depth validation (100 limit)
- 7 tests for DependencyGraph.getMaxDepth() algorithm
- **All 221 unit tests passing** (was 203 tests before v0.3.1)

## [0.3.0] - 2025-10-18

### 🚀 Major Features
Expand Down
66 changes: 66 additions & 0 deletions src/core/dependency-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,4 +350,70 @@ export class DependencyGraph {
hasTask(taskId: TaskId): boolean {
return this.graph.has(taskId as string);
}

/**
* Calculate the maximum dependency chain depth from a given task
*
* The depth is the longest path from the task through its transitive dependencies.
* Used to prevent stack overflow from excessively deep dependency chains.
*
* Algorithm: DFS with memoization to compute longest path to leaf nodes
*
* @param taskId - The task to calculate max depth for
* @returns Max depth (number of edges in longest dependency chain)
*
* @example
* ```typescript
* // A -> B -> C -> D has depth 3
* // A -> [B, C] where B -> D has depth 2
* const depth = graph.getMaxDepth(taskA.id);
* if (depth > 100) {
* // Chain too deep
* }
* ```
*/
getMaxDepth(taskId: TaskId): number {
const taskIdStr = taskId as string;
const memo = new Map<string, number>();

/**
* Recursive DFS to calculate max depth with memoization
* Prevents exponential time complexity on diamond-shaped graphs
*/
const calculateDepth = (node: string, currentPath: Set<string>): number => {
// Return memoized result if available
if (memo.has(node)) {
return memo.get(node)!;
}

const deps = this.graph.get(node);

// Leaf node has depth 0
if (!deps || deps.size === 0) {
memo.set(node, 0);
return 0;
}

// Track current path for cycle detection
currentPath.add(node);

// Calculate max depth of all dependencies
let maxDepth = 0;
for (const dep of deps) {
const depth = calculateDepth(dep, currentPath);
maxDepth = Math.max(maxDepth, depth);
}

// Remove from current path (backtrack)
currentPath.delete(node);

// Depth is 1 + max depth of dependencies
const nodeDepth = maxDepth + 1;
memo.set(node, nodeDepth);

return nodeDepth;
};

return calculateDepth(taskIdStr, new Set());
}
}
7 changes: 7 additions & 0 deletions src/core/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ export interface DependencyRepository {
*/
addDependency(taskId: TaskId, dependsOnTaskId: TaskId): Promise<Result<TaskDependency>>;

/**
* Add multiple dependencies atomically in a single transaction
* All dependencies succeed or all fail together
* @returns Error if any dependency would create a cycle or if validation fails
*/
addDependencies(taskId: TaskId, dependsOn: readonly TaskId[]): Promise<Result<readonly TaskDependency[]>>;

/**
* Get all tasks that the given task depends on (blocking tasks)
*/
Expand Down
176 changes: 136 additions & 40 deletions src/implementations/dependency-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { Database } from './database.js';
import { DependencyGraph } from '../core/dependency-graph.js';

export class SQLiteDependencyRepository implements DependencyRepository {
// SECURITY: Hard limits to prevent DoS attacks and stack overflow
private static readonly MAX_DEPENDENCIES_PER_TASK = 100;
private static readonly MAX_DEPENDENCY_CHAIN_DEPTH = 100;

private readonly db: SQLite.Database;
private readonly addDependencyStmt: SQLite.Statement;
private readonly getDependenciesStmt: SQLite.Statement;
Expand Down Expand Up @@ -110,15 +114,68 @@ export class SQLiteDependencyRepository implements DependencyRepository {
* ```
*/
async addDependency(taskId: TaskId, dependsOnTaskId: TaskId): Promise<Result<TaskDependency>> {
// REFACTOR: Delegate to addDependencies() to eliminate duplicate validation logic
// This centralizes all validation (task existence, cycle detection, depth check, etc.)
// in a single location, improving maintainability and consistency
const batchResult = await this.addDependencies(taskId, [dependsOnTaskId]);

if (!batchResult.ok) {
return batchResult;
}

// Extract the single dependency from the batch result
return ok(batchResult.value[0]);
}

/**
* Add multiple dependencies atomically in a single transaction
*
* Uses synchronous better-sqlite3 transaction for atomicity.
* All dependencies succeed or all fail together (no partial state).
* Performs cycle detection for each proposed dependency before persisting any.
*
* @param taskId - The task that depends on other tasks
* @param dependsOn - Array of task IDs to depend on
* @returns Result containing array of created TaskDependency objects or error if:
* - Any dependency would create a cycle (ErrorCode.INVALID_OPERATION)
* - Any dependency already exists (ErrorCode.INVALID_OPERATION)
* - Any task doesn't exist (ErrorCode.TASK_NOT_FOUND)
* - Empty array provided (ErrorCode.INVALID_OPERATION)
*
* @example
* ```typescript
* const result = await dependencyRepo.addDependencies(taskC.id, [taskA.id, taskB.id]);
* if (!result.ok) {
* console.error('Failed to add dependencies:', result.error.message);
* } else {
* console.log(`Added ${result.value.length} dependencies atomically`);
* }
* ```
*/
async addDependencies(taskId: TaskId, dependsOn: readonly TaskId[]): Promise<Result<readonly TaskDependency[]>> {
// VALIDATION: Reject empty arrays
if (dependsOn.length === 0) {
return err(new ClaudineError(
ErrorCode.INVALID_OPERATION,
'Cannot add dependencies: empty array provided'
));
}

// SECURITY: Prevent DoS attacks with excessive dependencies
// Limit to MAX_DEPENDENCIES_PER_TASK for reasonable production workflows
if (dependsOn.length > SQLiteDependencyRepository.MAX_DEPENDENCIES_PER_TASK) {
return err(new ClaudineError(
ErrorCode.INVALID_OPERATION,
`Cannot add ${dependsOn.length} dependencies: task cannot have more than ${SQLiteDependencyRepository.MAX_DEPENDENCIES_PER_TASK} dependencies`
));
}

// SECURITY: TOCTOU Fix - Use synchronous .transaction() for true atomicity
// Per Wikipedia TOCTOU principles: check and use must be atomic
// better-sqlite3's .transaction() ensures no JavaScript event loop interleaving
// Rationale: Async functions with BEGIN/COMMIT allow race conditions
const addDependencyTransaction = this.db.transaction((taskId: TaskId, dependsOnTaskId: TaskId) => {
// All validation and insertion happens within single atomic transaction
const addDependenciesTransaction = this.db.transaction((taskId: TaskId, dependsOn: readonly TaskId[]) => {
// ALL operations below are synchronous - no await, no yielding to event loop
// This guarantees atomicity: no other transaction can interleave

// VALIDATION: Check both tasks exist (foreign key validation)
// VALIDATION: Check dependent task exists
const taskExistsResult = this.checkTaskExistsStmt.get(taskId) as { count: number };
if (taskExistsResult.count === 0) {
throw new ClaudineError(
Expand All @@ -127,70 +184,109 @@ export class SQLiteDependencyRepository implements DependencyRepository {
);
}

const dependsOnTaskExistsResult = this.checkTaskExistsStmt.get(dependsOnTaskId) as { count: number };
if (dependsOnTaskExistsResult.count === 0) {
// SECURITY: Check current dependency count to prevent exceeding MAX_DEPENDENCIES_PER_TASK total
const existingDepsCount = (this.getDependenciesStmt.all(taskId) as Record<string, any>[]).length;
if (existingDepsCount + dependsOn.length > SQLiteDependencyRepository.MAX_DEPENDENCIES_PER_TASK) {
throw new ClaudineError(
ErrorCode.TASK_NOT_FOUND,
`Task not found: ${dependsOnTaskId}`
ErrorCode.INVALID_OPERATION,
`Cannot add ${dependsOn.length} dependencies: task would exceed maximum of ${SQLiteDependencyRepository.MAX_DEPENDENCIES_PER_TASK} dependencies (currently has ${existingDepsCount})`
);
}

// Check if dependency already exists
const existsResult = this.checkDependencyExistsStmt.get(taskId, dependsOnTaskId) as { count: number };
if (existsResult.count > 0) {
throw new ClaudineError(
ErrorCode.INVALID_OPERATION,
`Dependency already exists: ${taskId} depends on ${dependsOnTaskId}`
);
// VALIDATION: Check all dependency targets exist
for (const depId of dependsOn) {
const depExistsResult = this.checkTaskExistsStmt.get(depId) as { count: number };
if (depExistsResult.count === 0) {
throw new ClaudineError(
ErrorCode.TASK_NOT_FOUND,
`Task not found: ${depId}`
);
}
}

// VALIDATION: Check for existing dependencies
for (const depId of dependsOn) {
const existsResult = this.checkDependencyExistsStmt.get(taskId, depId) as { count: number };
if (existsResult.count > 0) {
throw new ClaudineError(
ErrorCode.INVALID_OPERATION,
`Dependency already exists: ${taskId} depends on ${depId}`
);
}
}

// Perform cycle detection using cached or newly built graph
// PERFORMANCE: Reuse cached graph if available, avoiding N+1 query problem
// Build dependency graph for cycle detection
let graph: DependencyGraph;
if (this.cachedGraph) {
graph = this.cachedGraph;
} else {
// Build graph from all dependencies (synchronous)
const allDepsRows = this.findAllStmt.all() as Record<string, any>[];
const allDeps = allDepsRows.map(row => this.rowToDependency(row));
graph = new DependencyGraph(allDeps);
this.cachedGraph = graph;
}

// Check for cycles (synchronous DFS algorithm)
const cycleCheck = graph.wouldCreateCycle(taskId, dependsOnTaskId);
// VALIDATION: Check each proposed dependency for cycles
for (const depId of dependsOn) {
const cycleCheck = graph.wouldCreateCycle(taskId, depId);

if (!cycleCheck.ok) {
throw cycleCheck.error;
if (!cycleCheck.ok) {
throw cycleCheck.error;
}

if (cycleCheck.value) {
throw new ClaudineError(
ErrorCode.INVALID_OPERATION,
`Cannot add dependency: would create cycle (${taskId} -> ${depId})`
);
}
}

if (cycleCheck.value) {
// SECURITY: Check dependency chain depth to prevent stack overflow
// PERFORMANCE: Calculate max depth ONCE for all dependencies (not per-dependency in loop)
// This changes complexity from O(N * (V+E)) to O(V+E)
let maxDependencyDepth = 0;
let deepestTaskId: TaskId | null = null;

for (const depId of dependsOn) {
const depIdDepth = graph.getMaxDepth(depId);
if (depIdDepth > maxDependencyDepth) {
maxDependencyDepth = depIdDepth;
deepestTaskId = depId;
}
}

// Check if adding ANY of these dependencies would create chain > MAX_DEPENDENCY_CHAIN_DEPTH deep
// Depth calculation: 1 (taskId -> depId) + max depth among all depIds
const resultingDepth = 1 + maxDependencyDepth;
if (resultingDepth > SQLiteDependencyRepository.MAX_DEPENDENCY_CHAIN_DEPTH) {
throw new ClaudineError(
ErrorCode.INVALID_OPERATION,
`Cannot add dependency: would create cycle (${taskId} -> ${dependsOnTaskId})`
`Cannot add dependencies: would create dependency chain depth of ${resultingDepth} (maximum ${SQLiteDependencyRepository.MAX_DEPENDENCY_CHAIN_DEPTH}). Task ${deepestTaskId} has chain depth ${maxDependencyDepth}.`
);
}

// Insert dependency (synchronous)
// All validations passed - insert all dependencies atomically
const createdAt = Date.now();
const result = this.addDependencyStmt.run(taskId, dependsOnTaskId, createdAt);
const createdDependencies: TaskDependency[] = [];

// Fetch the created dependency (synchronous)
const row = this.getDependencyByIdStmt.get(result.lastInsertRowid) as Record<string, any>;
for (const depId of dependsOn) {
const result = this.addDependencyStmt.run(taskId, depId, createdAt);
const row = this.getDependencyByIdStmt.get(result.lastInsertRowid) as Record<string, any>;
createdDependencies.push(this.rowToDependency(row));
}

// PERFORMANCE: Invalidate cache after successful insertion
// This ensures next cycle detection builds fresh graph with new dependency
// PERFORMANCE: Invalidate cache after successful batch insertion
this.cachedGraph = null;

return this.rowToDependency(row);
return createdDependencies;
});

// Execute the transaction and wrap result
return tryCatch(
() => addDependencyTransaction(taskId, dependsOnTaskId),
() => addDependenciesTransaction(taskId, dependsOn),
(error) => {
// Preserve semantic ClaudineError types (TASK_NOT_FOUND, INVALID_OPERATION for cycles)
// This allows upstream code to handle validation failures vs system errors correctly
// Preserve semantic ClaudineError types
if (error instanceof ClaudineError) {
return error;
}
Expand All @@ -199,16 +295,16 @@ export class SQLiteDependencyRepository implements DependencyRepository {
if (error instanceof Error && error.message.includes('UNIQUE constraint')) {
return new ClaudineError(
ErrorCode.INVALID_OPERATION,
`Dependency already exists: ${taskId} depends on ${dependsOnTaskId}`,
{ taskId, dependsOnTaskId }
`One or more dependencies already exist for task: ${taskId}`,
{ taskId, dependsOn }
);
}

// Unknown errors become SYSTEM_ERROR
return new ClaudineError(
ErrorCode.SYSTEM_ERROR,
`Failed to add dependency: ${error}`,
{ taskId, dependsOnTaskId }
`Failed to add dependencies: ${error}`,
{ taskId, dependsOn }
);
}
);
Expand Down
Loading