Skip to content

Commit 103420e

Browse files
gfraiteurclaude
andcommitted
Implement two-layer MCP approval system with AI and regex validation
Added a defense-in-depth approach to MCP command approval: - Layer 1: AI-driven risk analysis (existing RiskAnalyzer) - Layer 2: Regex-based rule engine with git context awareness - Both layers run in parallel for optimal performance - Final risk level is the maximum (most restrictive) of both assessments New components: - RegexRuleEngine: Evaluates commands against pattern-based rules with git context - CommandRules: 30+ predefined rules covering git operations, GitHub CLI, file operations, package publishing, and security-sensitive operations - RiskCombiner: Combines assessments from both layers - CommandContext: Captures git branch, remote URL, and merge status - CommandRule: Declarative rule model with regex patterns and optional context conditions Updated components: - ExecuteCommandTool: Runs both analyzers in parallel using Task.WhenAll - ApprovalPrompter: Displays AI assessment, regex assessment, and combined final decision - McpServerCommand: Registers new services in DI container - RiskAssessment: Added RuleName property for audit logging Protected branches: main, master, develop/*, release/* All file operations are rejected (must be done in container) All package publishing is rejected (must be done through CI/CD) Fixed Spectre.Console markup error in RegexRuleEngine by escaping square brackets and adding Markup.Escape() for rule properties. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent 3c0f31f commit 103420e

File tree

14 files changed

+719
-24
lines changed

14 files changed

+719
-24
lines changed

DockerBuild.ps1

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,12 +560,19 @@ if (-not $BuildImage)
560560
# Start MCP approval server on host with dynamic port in new terminal tab
561561
$mcpPort = $null
562562
$mcpPortFile = $null
563+
$mcpSecret = $null
563564
if (-not $NoMcp) {
564565
Write-Host "Starting MCP approval server..." -ForegroundColor Green
565566
$mcpPortFile = Join-Path $env:TEMP "mcp-port-$([System.Guid]::NewGuid().ToString('N').Substring(0,8)).txt"
566567

568+
# Generate 128-bit (16 byte) random secret for authentication
569+
$randomBytes = New-Object byte[] 16
570+
[Security.Cryptography.RandomNumberGenerator]::Fill($randomBytes)
571+
$mcpSecret = [Convert]::ToBase64String($randomBytes)
572+
Write-Host "Generated MCP authentication secret" -ForegroundColor Cyan
573+
567574
# Build the command to run in the new tab
568-
$mcpCommand = "& '$SourceDirName\Build.ps1' tools mcp-server --port-file '$mcpPortFile'"
575+
$mcpCommand = "& '$SourceDirName\Build.ps1' tools mcp-server --port-file '$mcpPortFile' --secret '$mcpSecret'"
569576

570577
# Try Windows Terminal first (wt.exe), fall back to conhost
571578
$wtPath = Get-Command wt.exe -ErrorAction SilentlyContinue
@@ -676,6 +683,11 @@ if (-not $BuildImage)
676683
"-e", "USERPROFILE=$containerUserProfile"
677684
)
678685

686+
# Pass MCP secret to container if MCP server is running
687+
if ($mcpSecret) {
688+
$envArgs += @("-e", "MCP_APPROVAL_SERVER_TOKEN=$mcpSecret")
689+
}
690+
679691
Write-Host "Executing: ``docker run --rm --memory=12g $dockerArgsAsString $VolumeMappingsAsString -e HOME=$containerUserProfile -e USERPROFILE=$containerUserProfile -w $ContainerSourceDir $ImageTag `"$pwshPath`" -Command `"$inlineScript`"" -ForegroundColor Cyan
680692

681693
try {

Dockerfile.claude

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,19 @@ ENV PATH="C:\Program Files\GitHub CLI;${PATH}"
8080

8181
# Install Claude CLI
8282
# Configure npm global directory to avoid Windows container path issues
83-
ENV NPM_CONFIG_PREFIX=C:\npm
84-
ENV PATH="C:\npm;${PATH}"
83+
ENV NPM_CONFIG_PREFIX=C:\\npm
84+
ENV PATH="C:\\npm;${PATH}"
8585

8686
# Install Claude CLI using cmd shell to avoid HCS issues with PowerShell
8787
SHELL ["cmd", "/S", "/C"]
88-
RUN C:\nodejs\npm.cmd install --global @anthropic-ai/claude-code
88+
RUN C:\\nodejs\\npm.cmd install --global @anthropic-ai/claude-code
89+
90+
# Set HOME/USERPROFILE so Claude CLI finds credentials during build
91+
ENV HOME=C:\\Users\\ContainerUser
92+
ENV USERPROFILE=C:\\Users\\ContainerUser
93+
94+
# Create Claude config directory (credentials are mounted at runtime)
95+
RUN mkdir C:\Users\ContainerUser\.claude
8996

9097
# Restore PowerShell shell using full path
9198
SHELL ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-Command"]

eng/RunClaude.ps1

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,17 @@ if ($env:RUNNING_IN_DOCKER -ne "true")
1717
# Configure MCP approval server if port is specified
1818
$mcpConfigArg = ""
1919
if ($McpPort -gt 0) {
20-
$sseUrl = "http://host.docker.internal:$McpPort/sse"
21-
Write-Host "Configuring MCP approval server: $sseUrl" -ForegroundColor Cyan
20+
# Get MCP secret from environment variable
21+
$mcpSecret = $env:MCP_APPROVAL_SERVER_TOKEN
22+
if ([string]::IsNullOrEmpty($mcpSecret)) {
23+
Write-Error "MCP_APPROVAL_SERVER_TOKEN environment variable is not set. Cannot authenticate to MCP server."
24+
exit 1
25+
}
26+
27+
# URL-encode the secret for path segment
28+
$encodedSecret = [System.Web.HttpUtility]::UrlEncode($mcpSecret)
29+
$sseUrl = "http://host.docker.internal:$McpPort/$encodedSecret/sse"
30+
Write-Host "Configuring MCP approval server (authenticated)" -ForegroundColor Cyan
2231

2332
# Create temporary MCP config file
2433
$mcpConfigPath = "$env:TEMP\mcp-config.json"

src/PostSharp.Engineering.BuildTools/Docker/ClaudeComponent.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public override void WriteDockerfile( TextWriter writer )
5656
RUN mkdir C:\Users\ContainerUser\.claude
5757
""" );
5858

59+
/*
5960
// Add marketplaces if any are specified
6061
foreach ( var marketplace in this.Marketplaces )
6162
{
@@ -67,7 +68,7 @@ public override void WriteDockerfile( TextWriter writer )
6768
{
6869
writer.WriteLine( $"RUN C:\\npm\\claude.cmd plugin install {plugin}" );
6970
}
70-
71+
*/
7172
writer.WriteLine(
7273
"""
7374

src/PostSharp.Engineering.BuildTools/Mcp/McpServerCommand.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,13 @@ public override async Task<int> ExecuteAsync( CommandContext context, McpServerC
3838
// Register services for tool dependencies
3939
builder.Services.AddSingleton<CommandHistoryService>();
4040
builder.Services.AddSingleton<RiskAnalyzer>();
41+
builder.Services.AddSingleton<RegexRuleEngine>();
4142
builder.Services.AddSingleton<ApprovalPrompter>();
4243
builder.Services.AddSingleton<CommandExecutor>();
4344

45+
// Register ConsoleHelper for GitHelper usage
46+
builder.Services.AddSingleton( _ => new PostSharp.Engineering.BuildTools.Utilities.ConsoleHelper() );
47+
4448
// Register the tool itself
4549
builder.Services.AddScoped<ExecuteCommandTool>();
4650

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
2+
3+
namespace PostSharp.Engineering.BuildTools.Mcp.Models;
4+
5+
/// <summary>
6+
/// Contextual information about a command execution environment, used for risk assessment.
7+
/// </summary>
8+
public sealed record CommandContext
9+
{
10+
/// <summary>
11+
/// Gets the command to be executed.
12+
/// </summary>
13+
public required string Command { get; init; }
14+
15+
/// <summary>
16+
/// Gets the working directory for command execution.
17+
/// </summary>
18+
public required string WorkingDirectory { get; init; }
19+
20+
/// <summary>
21+
/// Gets the current git branch, if available.
22+
/// </summary>
23+
public string? CurrentBranch { get; init; }
24+
25+
/// <summary>
26+
/// Gets the remote URL for the git repository, if available.
27+
/// </summary>
28+
public string? RemoteUrl { get; init; }
29+
30+
/// <summary>
31+
/// Gets a value indicating whether a git merge is currently in progress.
32+
/// </summary>
33+
public bool IsMergeInProgress { get; init; }
34+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
2+
3+
using System;
4+
using System.Text.RegularExpressions;
5+
6+
namespace PostSharp.Engineering.BuildTools.Mcp.Models;
7+
8+
/// <summary>
9+
/// Defines a regex-based rule for evaluating command risk.
10+
/// </summary>
11+
public sealed class CommandRule
12+
{
13+
/// <summary>
14+
/// Gets the unique name of the rule.
15+
/// </summary>
16+
public required string Name { get; init; }
17+
18+
/// <summary>
19+
/// Gets the regex pattern to match against the command.
20+
/// </summary>
21+
public required Regex Pattern { get; init; }
22+
23+
/// <summary>
24+
/// Gets the risk level if this rule matches.
25+
/// </summary>
26+
public required RiskLevel RiskLevel { get; init; }
27+
28+
/// <summary>
29+
/// Gets the recommendation if this rule matches.
30+
/// </summary>
31+
public required Recommendation Recommendation { get; init; }
32+
33+
/// <summary>
34+
/// Gets the reason explaining why this rule triggered.
35+
/// </summary>
36+
public required string Reason { get; init; }
37+
38+
/// <summary>
39+
/// Gets an optional condition function that must be satisfied for the rule to apply.
40+
/// If null, the rule applies whenever the pattern matches.
41+
/// </summary>
42+
public Func<CommandContext, bool>? Condition { get; init; }
43+
}

src/PostSharp.Engineering.BuildTools/Mcp/Models/RiskAssessment.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ public sealed class RiskAssessment
3535

3636
public required string Reason { get; init; }
3737

38+
/// <summary>
39+
/// Gets the name of the rule that triggered this assessment (for regex-based rules).
40+
/// Null for AI-driven assessments.
41+
/// </summary>
42+
public string? RuleName { get; init; }
43+
3844
public static RiskAssessment Default( string reason )
3945
{
4046
return new RiskAssessment

src/PostSharp.Engineering.BuildTools/Mcp/Services/ApprovalPrompter.cs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ public Task<bool> RequestApprovalAsync(
2222
string command,
2323
string claimedPurpose,
2424
string workingDirectory,
25-
RiskAssessment assessment )
25+
RiskAssessment combinedAssessment,
26+
RiskAssessment aiAssessment,
27+
RiskAssessment regexAssessment )
2628
#pragma warning restore CA1822
2729
{
2830
// Beep to alert the user
@@ -44,12 +46,12 @@ public Task<bool> RequestApprovalAsync(
4446

4547
try
4648
{
47-
// Auto-approve LOW risk commands when AI recommends approval
48-
if ( assessment.Level == RiskLevel.Low && assessment.Recommendation == Recommendation.Approve )
49+
// Auto-approve LOW risk commands when combined assessment recommends approval
50+
if ( combinedAssessment.Level == RiskLevel.Low && combinedAssessment.Recommendation == Recommendation.Approve )
4951
{
5052
AnsiConsole.WriteLine();
5153
AnsiConsole.MarkupLine( "[green]Auto-approved (LOW risk)[/]" );
52-
AnsiConsole.MarkupLine( $"[dim]Reason: {Markup.Escape( assessment.Reason )}[/]" );
54+
AnsiConsole.MarkupLine( $"[dim]Reason: {Markup.Escape( combinedAssessment.Reason )}[/]" );
5355
AnsiConsole.WriteLine();
5456

5557
return Task.FromResult( true );
@@ -69,15 +71,37 @@ public Task<bool> RequestApprovalAsync(
6971
table.AddRow( "[bold]Command[/]", $"[white]{Markup.Escape( command )}[/]" );
7072
table.AddRow( "[bold]Working Directory[/]", $"[blue]{Markup.Escape( workingDirectory )}[/]" );
7173
table.AddRow( "[bold]Purpose[/]", $"[dim]{Markup.Escape( claimedPurpose )}[/]" );
72-
table.AddRow( "[bold]Risk Level[/]", GetRiskMarkup( assessment.Level ) );
73-
table.AddRow( "[bold]AI Recommendation[/]", GetRecommendationMarkup( assessment.Recommendation ) );
74-
table.AddRow( "[bold]Reason[/]", $"[dim]{Markup.Escape( assessment.Reason )}[/]" );
74+
75+
// AI Assessment section
76+
table.AddRow( "", "" ); // Empty row for spacing
77+
table.AddRow( "[bold yellow]AI Assessment[/]", "" );
78+
table.AddRow( " Risk Level", GetRiskMarkup( aiAssessment.Level ) );
79+
table.AddRow( " Recommendation", GetRecommendationMarkup( aiAssessment.Recommendation ) );
80+
table.AddRow( " Reason", $"[dim]{Markup.Escape( aiAssessment.Reason )}[/]" );
81+
82+
// Regex Assessment section
83+
table.AddRow( "", "" ); // Empty row for spacing
84+
table.AddRow( "[bold cyan]Regex Assessment[/]", "" );
85+
table.AddRow( " Risk Level", GetRiskMarkup( regexAssessment.Level ) );
86+
table.AddRow( " Recommendation", GetRecommendationMarkup( regexAssessment.Recommendation ) );
87+
table.AddRow( " Reason", $"[dim]{Markup.Escape( regexAssessment.Reason )}[/]" );
88+
89+
if ( regexAssessment.RuleName != null )
90+
{
91+
table.AddRow( " Rule Name", $"[dim]{Markup.Escape( regexAssessment.RuleName )}[/]" );
92+
}
93+
94+
// Combined Assessment section
95+
table.AddRow( "", "" ); // Empty row for spacing
96+
table.AddRow( "[bold green]Combined (Final)[/]", "" );
97+
table.AddRow( " Risk Level", GetRiskMarkup( combinedAssessment.Level ) );
98+
table.AddRow( " Recommendation", GetRecommendationMarkup( combinedAssessment.Recommendation ) );
7599

76100
AnsiConsole.Write( table );
77101
AnsiConsole.WriteLine();
78102

79-
// Default to AI recommendation
80-
var defaultApprove = assessment.Recommendation == Recommendation.Approve;
103+
// Default to combined recommendation
104+
var defaultApprove = combinedAssessment.Recommendation == Recommendation.Approve;
81105
var approved = AnsiConsole.Confirm( "Approve this command?", defaultValue: defaultApprove );
82106

83107
return Task.FromResult( approved );

0 commit comments

Comments
 (0)