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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ jobs:
- name: Unit tests (with race detector)
run: go test -race ./... -short

e2e:
name: E2E Tests (MCP)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.24.13'

- uses: actions/setup-node@v4
with:
node-version: '22'

- name: Pre-install MCP filesystem server
run: npm install -g @modelcontextprotocol/server-filesystem

- name: E2E tests
run: go test ./internal/mcpgateway/... -v -run E2E -timeout 120s

docker-test:
name: Docker
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Thumbs.db

# VHS recordings (keep only GIF)
docs/*.webm
docs/*.mp4

# Agent test local config (contains API keys)
tests/agents/config.local.yaml
Expand Down
5 changes: 5 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ linters:
linters:
- revive
text: "var-naming: avoid meaningless package names"
# "jsonrpc" conflicts with net/rpc/jsonrpc but is the clearest name for this package
- path: internal/jsonrpc/
linters:
- revive
text: "var-naming: avoid package names that conflict"

issues:
max-issues-per-linter: 50
Expand Down
35 changes: 27 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<p align="center">
<a href="https://getcrust.io">Website</a> •
<a href="#quick-start">Quick Start</a> •
<a href="#mcp-gateway">MCP</a> •
<a href="#acp-integration">ACP</a> •
<a href="#built-in-protection">Protection</a> •
<a href="#how-it-works">How It Works</a> •
Expand Down Expand Up @@ -104,6 +105,16 @@ crust doctor # Diagnose provider endpoints
crust stop # Stop crust
```

## MCP Gateway

For [MCP](https://modelcontextprotocol.io) servers, Crust intercepts `tools/call` and `resources/read` requests before they reach the server.

```bash
crust mcp-gateway -- npx -y @modelcontextprotocol/server-filesystem /path/to/dir
```

Works with any MCP server. See the [MCP setup guide](docs/mcp.md) for details and examples.

## ACP Integration

For IDEs that use the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), Crust can wrap any ACP agent as a transparent stdio proxy — intercepting file reads, writes, and terminal commands before the IDE executes them. No changes to the agent or IDE required.
Expand Down Expand Up @@ -170,23 +181,31 @@ Crust inspects tool calls at multiple layers:

1. **Layer 0 (Request Scan)**: Scans tool calls in conversation history before they reach the LLM — catches agents replaying dangerous actions.
2. **Layer 1 (Response Scan)**: Scans tool calls in the LLM's response before they execute — blocks new dangerous actions in real-time.
3. **ACP Mode**: Wraps ACP agents as a stdio proxy, intercepting JSON-RPC file/terminal requests before the IDE executes them.
3. **Stdio Proxy** ([MCP](docs/mcp.md) / [ACP](docs/acp.md)): Wraps MCP servers or ACP agents as a stdio proxy, intercepting security-relevant JSON-RPC messages in both directions — including DLP scanning of server responses for leaked secrets.

Layers 0–1 apply a [10-step evaluation pipeline](docs/how-it-works.md) — input sanitization, Unicode normalization, obfuscation detection, DLP secret scanning, path-based rules, and fallback content matching — each step in microseconds. ACP mode reuses the same rule engine.
All modes apply a [10-step evaluation pipeline](docs/how-it-works.md) — input sanitization, Unicode normalization, obfuscation detection, DLP secret scanning, path-based rules, and fallback content matching — each step in microseconds.

All activity is logged locally to encrypted storage.

## Documentation

**Setup**

| Guide | Description |
|-------|-------------|
| [Configuration](docs/configuration.md) | Providers, auto mode, block modes |
| [MCP Gateway](docs/mcp.md) | Stdio proxy for [MCP](https://modelcontextprotocol.io) servers — Claude Desktop, custom servers |
| [ACP Integration](docs/acp.md) | Stdio proxy for [ACP](https://agentclientprotocol.com) agents — JetBrains, VS Code |
| [Docker](docs/docker.md) | Dockerfile, docker-compose, container setup |

**Reference**

| Guide | Description |
|-------|-------------|
| [Configuration](docs/configuration.md) | `config.yaml`, providers, auto mode, block modes |
| [CLI Reference](docs/cli.md) | Commands, flags, environment variables |
| [How It Works](docs/how-it-works.md) | Architecture, rule schema, protection categories |
| [Docker](docs/docker.md) | Dockerfile, docker-compose, TUI in containers |
| [Shell Parsing](docs/shell-parsing.md) | How Bash commands are parsed for path and command extraction |
| [Migration](docs/migration.md) | Upgrade guides for breaking changes between versions |
| [TUI Design](docs/tui.md) | Terminal UI internals, plain mode, Docker behavior |
| [How It Works](docs/how-it-works.md) | Architecture, rule engine, evaluation pipeline |
| [Shell Parsing](docs/shell-parsing.md) | Bash command parsing for path/command extraction |
| [Migration](docs/migration.md) | Upgrade guides for breaking changes |

## Build from Source

Expand Down
156 changes: 156 additions & 0 deletions cmd/mock-mcp-server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Package main implements a minimal mock MCP server for testing the Crust MCP gateway.
// It reads JSON-RPC 2.0 messages from stdin and writes responses to stdout.
package main

import (
"bufio"
"encoding/json"
"fmt"
"os"
)

type jsonRPCMessage struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
}

type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result"`
}

type jsonRPCError struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Error struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}

type toolsCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}

func main() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)

for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}

var msg jsonRPCMessage
if err := json.Unmarshal(line, &msg); err != nil {
continue
}

if msg.Method == "" || len(msg.ID) == 0 {
continue // not a request
}

switch msg.Method {
case "initialize":
respond(msg.ID, map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{
"tools": map[string]any{},
"resources": map[string]any{},
},
"serverInfo": map[string]any{
"name": "mock-mcp-server",
"version": "1.0.0",
},
})

case "tools/list":
respond(msg.ID, map[string]any{
"tools": []any{
map[string]any{
"name": "read_file",
"description": "Read a file",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"required": []string{"path"},
},
},
map[string]any{
"name": "write_file",
"description": "Write a file",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
"content": map[string]any{"type": "string"},
},
"required": []string{"path", "content"},
},
},
},
})

case "tools/call":
var p toolsCallParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
respondError(msg.ID, -32602, "invalid params")
continue
}
respond(msg.ID, map[string]any{
"content": []any{
map[string]any{
"type": "text",
"text": fmt.Sprintf("[mock] tool=%s executed successfully", p.Name),
},
},
})

case "resources/read":
respond(msg.ID, map[string]any{
"contents": []any{
map[string]any{
"uri": "file:///mock",
"mimeType": "text/plain",
"text": "[mock] resource content",
},
},
})

default:
respondError(msg.ID, -32601, "method not found: "+msg.Method)
}
}
}

func respond(id json.RawMessage, result any) {
resp, err := json.Marshal(jsonRPCResponse{
JSONRPC: "2.0",
ID: id,
Result: result,
})
if err != nil {
fmt.Fprintf(os.Stderr, "marshal error: %v\n", err)
return
}
fmt.Fprintf(os.Stdout, "%s\n", resp)
}

func respondError(id json.RawMessage, code int, msg string) {
resp := jsonRPCError{JSONRPC: "2.0", ID: id}
resp.Error.Code = code
resp.Error.Message = msg
data, err := json.Marshal(resp)
if err != nil {
fmt.Fprintf(os.Stderr, "marshal error: %v\n", err)
return
}
fmt.Fprintf(os.Stdout, "%s\n", data)
}
57 changes: 23 additions & 34 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,11 @@ Layer 1 Rule Evaluation Order:

**Layer 1 (Response Rules):** Scans LLM-generated tool_calls in responses. Fast pattern matching with friendly error messages.

**ACP Mode (`crust acp-wrap`):** For IDEs using the [Agent Client Protocol](https://agentclientprotocol.com), Crust wraps the agent as a transparent stdio proxy. Supports JetBrains IDEs and other ACP-compatible editors. Security-relevant JSON-RPC messages (`fs/read_text_file`, `fs/write_text_file`, `terminal/create`) are intercepted and evaluated by the same rule engine. Blocked requests never reach the IDE — the agent receives a JSON-RPC error response instead. See [ACP setup guide](acp.md) for configuration details.
**[MCP Gateway](mcp.md) (`crust mcp-gateway`):** Wraps [MCP](https://modelcontextprotocol.io) servers as a transparent stdio proxy. Inspects both directions — client→server requests (`tools/call`, `resources/read`) and server→client responses (DLP secret scanning). Works with any MCP server (filesystem, database, custom).

```text
IDE (JetBrains / any ACP-compatible editor)
│ stdin/stdout (JSON-RPC 2.0)
┌──────────────────────────────────────┐
│ crust acp-wrap │
│ │
│ Agent→IDE: inspect each request │
│ ├─ fs/read_text_file → Evaluate │
│ ├─ fs/write_text_file → Evaluate │
│ ├─ terminal/create → Evaluate │
│ └─ everything else → pass │
│ │
│ BLOCKED → JSON-RPC error to agent │
│ ALLOWED → forward to IDE unchanged │
└──────────────────────────────────────┘
│ stdin/stdout
Real ACP Agent (Goose, Gemini CLI, etc.)
```
**[ACP Mode](acp.md) (`crust acp-wrap`):** Wraps [ACP](https://agentclientprotocol.com) agents as a transparent stdio proxy. Intercepts `fs/read_text_file`, `fs/write_text_file`, and `terminal/create` requests. Supports JetBrains IDEs and other ACP-compatible editors.

**Auto-detect (`crust wrap`):** Inspects both MCP and ACP methods in both directions. Response DLP scans all server responses for leaked secrets. Method names are disjoint — no conflict.

---

Expand Down Expand Up @@ -91,26 +74,32 @@ Real ACP Agent (Goose, Gemini CLI, etc.)

## When Each Layer Blocks

| Attack | Layer 0 | Layer 1 | ACP Mode |
|--------|---------|---------|----------|
| Bad agent with secrets in history | ✅ Blocked | - | - |
| Poisoned conversation replay | ✅ Blocked | - | - |
| LLM generates `cat .env` | - | ✅ Blocked | - |
| LLM generates `rm -rf /etc` | - | ✅ Blocked | - |
| `$(cat .env)` obfuscation | - | ✅ Blocked | - |
| Symlink bypass | - | ✅ Blocked (composite) | - |
| Leaking real API keys/tokens | - | ✅ Blocked (DLP) | ✅ Blocked (DLP) |
| MCP plugin (e.g. Playwright) | - | ✅ Blocked (content-only) | - |
| ACP agent reads `.env` via IDE | - | - | ✅ Blocked |
| ACP agent reads SSH keys via IDE | - | - | ✅ Blocked |
| ACP agent runs `cat /etc/shadow` | - | - | ✅ Blocked |
| Attack | Layer 0 | Layer 1 | MCP Gateway | ACP Mode |
|--------|---------|---------|-------------|----------|
| Bad agent with secrets in history | ✅ Blocked | - | - | - |
| Poisoned conversation replay | ✅ Blocked | - | - | - |
| LLM generates `cat .env` | - | ✅ Blocked | - | - |
| LLM generates `rm -rf /etc` | - | ✅ Blocked | - | - |
| `$(cat .env)` obfuscation | - | ✅ Blocked | - | - |
| Symlink bypass | - | ✅ Blocked (composite) | - | - |
| Leaking real API keys/tokens | - | ✅ Blocked (DLP) | ✅ Blocked (DLP) | ✅ Blocked (DLP) |
| MCP client reads `.env` | - | - | ✅ Blocked (inbound) | - |
| MCP client reads SSH keys | - | - | ✅ Blocked (inbound) | - |
| MCP `resources/read file:///etc/shadow` | - | - | ✅ Blocked (inbound) | - |
| MCP server returns API keys in results | - | - | ✅ Blocked (response DLP) | - |
| MCP server returns tokens in results | - | - | ✅ Blocked (response DLP) | - |
| ACP agent reads `.env` via IDE | - | - | - | ✅ Blocked |
| ACP agent reads SSH keys via IDE | - | - | - | ✅ Blocked |
| ACP agent runs `cat /etc/shadow` | - | - | - | ✅ Blocked |

---

## DLP Secret Detection

Step 7 of the evaluation pipeline runs hardcoded DLP (Data Loss Prevention) patterns against all operations. These patterns detect real API keys and tokens by their format, regardless of file path or tool name.

In stdio proxy modes (MCP Gateway, ACP Wrap, Auto-detect), DLP also scans **server/agent responses** before they reach the client. This catches secrets leaked by the subprocess — for example, an MCP server returning file content that contains an AWS access key. The response is replaced with a JSON-RPC error so the secret never reaches the client.

| Provider | Pattern |
|----------|---------|
| AWS | Access key IDs (`AKIA...`, `ASIA...`) |
Expand Down
Loading