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
7 changes: 7 additions & 0 deletions src/graph/graphWebviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ApiKeyManager } from '../mcp/apiKeyManager';
import { AdaptiveDebouncer } from '../git/branchWatcher';
import { readGitMessageTemplate } from '../git/conventionalCommits';
import { RemoteManager } from '../remotes/remoteManager';
import { IpcSchemaValidator } from '../utils/ipcValidator';
import {
CommitFlowSettings,
DagQueryFilters,
Expand Down Expand Up @@ -397,6 +398,12 @@ export class GraphWebviewProvider

private setupMessageListener(webview: vscode.Webview): void {
webview.onDidReceiveMessage(async (msg: WebviewToExtensionMessage) => {
const validation = IpcSchemaValidator.validateWebviewMessage(msg);
if (!validation.valid) {
console.warn(`[CommitFlow IPC] Rejected message: ${validation.reason}`);
return;
}

const root = await this.getEffectiveRepoRoot();

switch (msg.command) {
Expand Down
99 changes: 97 additions & 2 deletions src/mcp/commitflowHttpServer.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,68 @@
import * as http from 'http';
import { ApiKeyManager } from './apiKeyManager';
import { CommitFlowMcpServer } from './commitflowMcpServer';
import { McpAuditLogEntry, sharedMcpAuditLog } from './mcpAuditLog';

export interface HttpServerOptions {
port?: number;
host?: string;
repoRoot?: string;
rateLimitMax?: number;
rateLimitWindowMs?: number;
}

export class CommitFlowHttpServer {
private server: http.Server | null = null;
private readonly apiKeyManager = ApiKeyManager.getInstance();
private readonly port: number;
private port: number;
private readonly host: string = '127.0.0.1';
private readonly repoRoot: string;
private readonly rateLimitMax: number;
private readonly rateLimitWindowMs: number;
private readonly rateLimitBuckets = new Map<string, number[]>();
private mcpEngine: CommitFlowMcpServer;
private sseClients: Set<http.ServerResponse> = new Set();

constructor(options?: HttpServerOptions) {
this.port = options?.port || 47291;
this.port = options?.port ?? 47291;
this.host = '127.0.0.1';
this.repoRoot = options?.repoRoot || process.cwd();
this.rateLimitMax = options?.rateLimitMax && options.rateLimitMax > 0 ? options.rateLimitMax : 120;
this.rateLimitWindowMs =
options?.rateLimitWindowMs && options.rateLimitWindowMs > 0 ? options.rateLimitWindowMs : 60_000;
this.mcpEngine = new CommitFlowMcpServer(this.repoRoot);
}

public checkRateLimit(
clientKey: string,
now: number = Date.now()
): { allowed: boolean; remaining: number; resetMs: number } {
const windowStart = now - this.rateLimitWindowMs;
const timestamps = (this.rateLimitBuckets.get(clientKey) || []).filter((ts) => ts > windowStart);

if (timestamps.length >= this.rateLimitMax) {
this.rateLimitBuckets.set(clientKey, timestamps);
const oldest = timestamps[0] || now;
return {
allowed: false,
remaining: 0,
resetMs: Math.max(1000, oldest + this.rateLimitWindowMs - now),
};
}

timestamps.push(now);
this.rateLimitBuckets.set(clientKey, timestamps);
return {
allowed: true,
remaining: Math.max(0, this.rateLimitMax - timestamps.length),
resetMs: this.rateLimitWindowMs,
};
}

public getAuditLogs(): McpAuditLogEntry[] {
return sharedMcpAuditLog.getAll();
}

public async start(): Promise<{ port: number; host: string }> {
return new Promise((resolve, reject) => {
this.server = http.createServer(async (req, res) => {
Expand All @@ -42,6 +81,10 @@ export class CommitFlowHttpServer {
});

this.server.listen(this.port, this.host, () => {
const addr = this.server?.address();
if (addr && typeof addr === 'object') {
this.port = addr.port;
}
resolve({ port: this.port, host: this.host });
});
});
Expand Down Expand Up @@ -80,13 +123,35 @@ export class CommitFlowHttpServer {
}

private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const startMs = Date.now();
const clientIp = req.socket?.remoteAddress || '127.0.0.1';
const method = req.method || 'GET';

const recordAudit = (
pathName: string,
status: number,
extra?: Partial<Omit<McpAuditLogEntry, 'timestamp' | 'transport' | 'method' | 'path' | 'status' | 'clientIp' | 'durationMs'>>
) => {
sharedMcpAuditLog.record({
timestamp: new Date().toISOString(),
transport: 'http',
method,
path: pathName,
status,
clientIp,
durationMs: Math.max(0, Date.now() - startMs),
...extra,
});
};

const origin = req.headers['origin'];
if (origin) {
const allowed =
origin.startsWith('http://127.0.0.1') ||
origin.startsWith('http://localhost') ||
origin.startsWith('vscode-webview://');
if (!allowed) {
recordAudit('/unknown', 403, { reason: 'invalid_origin' });
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
return;
Expand Down Expand Up @@ -116,19 +181,38 @@ export class CommitFlowHttpServer {
const token = authHeader || queryToken;

if (!token) {
recordAudit(pathname, 401, { reason: 'missing_token' });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized: missing API key token' }));
return;
}

const authValidation = await this.apiKeyManager.validateKey(token, 'read', this.repoRoot);
if (!authValidation.valid) {
recordAudit(pathname, 403, { reason: authValidation.reason || 'invalid_key' });
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Forbidden', reason: authValidation.reason }));
return;
}

const rateKey = authValidation.key?.id || clientIp;
const rate = this.checkRateLimit(rateKey);
res.setHeader('X-RateLimit-Limit', String(this.rateLimitMax));
res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
if (!rate.allowed) {
res.setHeader('Retry-After', String(Math.ceil(rate.resetMs / 1000)));
recordAudit(pathname, 429, {
keyId: authValidation.key?.id,
keyName: authValidation.key?.name,
reason: 'rate_limit_exceeded',
});
res.writeHead(429, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Too Many Requests', retryAfterMs: rate.resetMs }));
return;
}

if (pathname === '/sse' && req.method === 'GET') {
recordAudit(pathname, 200, { keyId: authValidation.key?.id, keyName: authValidation.key?.name });
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Expand All @@ -155,9 +239,19 @@ export class CommitFlowHttpServer {
try {
const jsonRpcRequest = JSON.parse(body);
const response = await this.mcpEngine.processDirectRequest(jsonRpcRequest, authValidation.key);
recordAudit(pathname, 200, {
keyId: authValidation.key?.id,
keyName: authValidation.key?.name,
rpcMethod: jsonRpcRequest?.method,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
} catch (err: any) {
recordAudit(pathname, 400, {
keyId: authValidation.key?.id,
keyName: authValidation.key?.name,
reason: err.message,
});
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
Expand All @@ -171,6 +265,7 @@ export class CommitFlowHttpServer {
return;
}

recordAudit(pathname, 404, { keyId: authValidation.key?.id, keyName: authValidation.key?.name });
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
}
Expand Down
33 changes: 32 additions & 1 deletion src/mcp/commitflowMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { RemoteManager } from '../remotes/remoteManager';
import { ApiKeyMetadata } from './apiKeyManager';
import { CloudPatch } from '../git/types';

import { sharedMcpAuditLog } from './mcpAuditLog';

export class CommitFlowMcpServer {
private readonly gitClient = GitClient.getInstance();
private readonly issueService = IssueService.getInstance();
Expand All @@ -18,7 +20,20 @@ export class CommitFlowMcpServer {
}

public async processDirectRequest(req: any, clientKey?: ApiKeyMetadata): Promise<any> {
return this.handleJsonRpc(req, clientKey);
const startMs = Date.now();
const res = await this.handleJsonRpc(req, clientKey);
if (!clientKey) {
sharedMcpAuditLog.record({
timestamp: new Date().toISOString(),
transport: 'stdio',
method: 'RPC',
path: 'stdio://json-rpc',
status: res?.error ? 400 : 200,
rpcMethod: req?.method,
durationMs: Math.max(0, Date.now() - startMs),
});
}
return res;
}

public async start(): Promise<void> {
Expand Down Expand Up @@ -529,6 +544,12 @@ export class CommitFlowMcpServer {
description: 'Real-time bisect status, current testing probe commit, remaining steps, and good/bad hashes.',
mimeType: 'application/json',
},
{
uri: 'commitflow://mcp/audit-log',
name: 'MCP Security & Request Audit Log',
description: 'Structured ring-buffer audit trail of recent MCP HTTP and stdio requests including rate-limit and auth rejections.',
mimeType: 'application/json',
},
],
},
};
Expand Down Expand Up @@ -1050,6 +1071,16 @@ export class CommitFlowMcpServer {
const bisectStatus = await this.gitClient.getBisectStatus(this.repoRoot);
contentText = JSON.stringify(bisectStatus, null, 2);
mimeType = 'application/json';
} else if (uri === 'commitflow://mcp/audit-log') {
contentText = JSON.stringify(
{
count: sharedMcpAuditLog.getAll().length,
entries: sharedMcpAuditLog.getAll(),
},
null,
2
);
mimeType = 'application/json';
} else {
return {
jsonrpc: '2.0',
Expand Down
39 changes: 39 additions & 0 deletions src/mcp/mcpAuditLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export interface McpAuditLogEntry {
timestamp: string;
transport: 'http' | 'stdio';
method: string;
path: string;
status: number;
rpcMethod?: string;
keyId?: string;
keyName?: string;
clientIp?: string;
durationMs: number;
reason?: string;
}

/**
* Bounded in-memory ring buffer storing recent MCP security and RPC audit events.
*/
export class McpAuditRingBuffer {
private readonly entries: McpAuditLogEntry[] = [];

constructor(private readonly capacity: number = 500) {}

public record(entry: McpAuditLogEntry): void {
if (this.entries.length >= this.capacity) {
this.entries.shift();
}
this.entries.push(entry);
}

public getAll(): McpAuditLogEntry[] {
return [...this.entries];
}

public clear(): void {
this.entries.length = 0;
}
}

export const sharedMcpAuditLog = new McpAuditRingBuffer(500);
Loading
Loading