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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Stop copy-pasting terminal output into your AI. Let your LLM SSH in and look around.

ShellGuard is an [MCP](https://modelcontextprotocol.io/) server that gives LLM agents read-only bash access to remote servers over SSH. Connect your AI to production, staging, or dev servers and let it run diagnostics, inspect logs, query databases, and troubleshoot -- hands-free.
ShellGuard is an [MCP](https://modelcontextprotocol.io/) server that gives LLM agents controlled bash access to remote servers over SSH. Connect your AI to production, staging, or dev servers and let it run diagnostics, inspect logs, query databases, and troubleshoot -- hands-free.

Commands are restricted to a curated set of read-only tools. Destructive operations are blocked with actionable suggestions so the LLM can self-correct and keep investigating:
Commands are restricted to a curated set of observation and diagnostic tools. Destructive operations are blocked with actionable suggestions so the LLM can self-correct and keep investigating:

- `wget -r` -> `"Recursive downloading is not allowed"`
- `tail -f` -> `"Follow mode hangs until timeout. Use tail -n 100 for recent lines."`
Expand Down Expand Up @@ -186,17 +186,18 @@ Or add the following to your Roo Code MCP settings file. See [Roo Code MCP docs]

## What It Does

ShellGuard exposes 7 tools to the LLM:
ShellGuard exposes 6 tools to the LLM:

| Tool | Description |
| --------------- | ------------------------------------------------------------- |
| `connect` | Establish an SSH connection to a remote host |
| `execute` | Run a read-only shell command on the remote host |
| `list_commands` | List available commands, optionally filtered by category |
| `execute` | Run a validated shell command on the remote host |
| `disconnect` | Close SSH connection(s) |
| `sleep` | Wait between diagnostic checks (max 15s) |
| `provision` | Deploy diagnostic tools (`rg`, `jq`, `yq`) to the remote host |
| `download_file` | Download a file from the remote host via SFTP (50MB limit) |
| `sleep` | Wait between diagnostic checks (max 15s) |

`provision`, `download_file`, and `sleep` can be disabled via the `disabled_tools` config option or `SHELLGUARD_DISABLED_TOOLS` environment variable.

The LLM connects to a server, runs commands, and reads the output -- the same workflow you'd do manually, but without the context-switching.

Expand Down
9 changes: 4 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

ShellGuard is a security-first MCP (Model Context Protocol) server that enables LLM agents to execute read-only shell commands on remote servers over SSH. It implements a defense-in-depth pipeline that parses, validates, and reconstructs every command before execution, ensuring only non-destructive operations reach the remote host.
ShellGuard is a security-first MCP (Model Context Protocol) server that enables LLM agents to execute validated shell commands on remote servers over SSH. It implements a defense-in-depth pipeline that parses, validates, and reconstructs every command before execution, ensuring only non-destructive operations reach the remote host.

## System Architecture

Expand Down Expand Up @@ -141,17 +141,16 @@ Top-level constructor and convenience functions. Provides `New(Config)` to creat

### `server`

The MCP server core. Wires together all packages and registers 7 MCP tools:
The MCP server core. Wires together all packages and registers 6 MCP tools:

| Tool | Description |
| --------------- | ------------------------------------------------------------------- |
| `connect` | Establish SSH connection to a remote host; probes for toolkit tools |
| `execute` | Run a command through the security pipeline |
| `list_commands` | List allowed commands, optionally filtered by category |
| `disconnect` | Close SSH connection(s) |
| `sleep` | Local sleep (max 15s) for use between diagnostic checks |
| `provision` | Deploy diagnostic tools (`rg`, `jq`, `yq`) to remote host via SFTP |
| `download_file` | Download a file from remote via SFTP (50MB limit) |
| `sleep` | Local sleep (max 15s) for use between diagnostic checks |

The `Executor` interface abstracts the execution backend, enabling non-SSH implementations (Docker exec, local exec, test mocks) without modifying the security pipeline.

Expand Down Expand Up @@ -236,7 +235,7 @@ Supports `x86_64` and `aarch64`. Binaries are cached locally at `~/.cache/shellg

## Security Model

ShellGuard enforces a **read-only, observational posture** through layered defenses:
ShellGuard enforces an **observational posture** through layered defenses:

1. **Syntax restriction** (parser) -- eliminates shell features that enable code execution, data exfiltration, or state mutation at the syntax level
2. **Command allowlisting** (validator + manifest) -- only explicitly approved commands pass; flags are individually controlled; SQL is validated for read-only semantics
Expand Down
74 changes: 45 additions & 29 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type Core struct {
MaxDownloadBytes int
DownloadDir string
MaxSleepSeconds int
DisabledTools map[string]bool

logger *slog.Logger
mu sync.RWMutex
Expand Down Expand Up @@ -127,6 +128,15 @@ func WithMaxSleepSeconds(seconds int) CoreOption {
return func(c *Core) { c.MaxSleepSeconds = seconds }
}

func WithDisabledTools(tools []string) CoreOption {
return func(c *Core) {
c.DisabledTools = make(map[string]bool, len(tools))
for _, t := range tools {
c.DisabledTools[t] = true
}
}
}

func NewCore(registry map[string]*manifest.Manifest, runner Executor, logger *slog.Logger, opts ...CoreOption) *Core {
if logger == nil {
logger = slog.New(slog.NewTextHandler(io.Discard, nil))
Expand Down Expand Up @@ -717,38 +727,44 @@ func NewMCPServer(core *Core, opts ...ServerOptions) *mcp.Server {
return nil, out, err
})

mcp.AddTool(srv, &mcp.Tool{Name: "sleep", Description: fmt.Sprintf("Sleep locally for a specified duration (max %d seconds). Use to wait between checks, e.g. after observing an issue and before re-checking.", core.MaxSleepSeconds)},
func(ctx context.Context, _ *mcp.CallToolRequest, in SleepInput) (*mcp.CallToolResult, map[string]any, error) {
out, err := core.Sleep(ctx, in)
if !core.DisabledTools["sleep"] {
mcp.AddTool(srv, &mcp.Tool{Name: "sleep", Description: fmt.Sprintf("Sleep locally for a specified duration (max %d seconds). Use to wait between checks, e.g. after observing an issue and before re-checking.", core.MaxSleepSeconds)},
func(ctx context.Context, _ *mcp.CallToolRequest, in SleepInput) (*mcp.CallToolResult, map[string]any, error) {
out, err := core.Sleep(ctx, in)
return nil, out, err
})
}

if !core.DisabledTools["provision"] {
mcp.AddTool(srv, &mcp.Tool{
Name: "provision",
Description: "Deploy missing diagnostic tools (rg, jq, yq) to ~/.shellguard/bin/ on the remote server. Uses SFTP over the existing SSH connection -- no outbound internet required on the remote. This is a WRITE operation: ask the operator for approval before calling this tool.",
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: false,
IdempotentHint: true,
},
}, func(ctx context.Context, _ *mcp.CallToolRequest, in ProvisionInput) (*mcp.CallToolResult, map[string]any, error) {
out, err := core.Provision(ctx, in)
return nil, out, err
})
}

mcp.AddTool(srv, &mcp.Tool{
Name: "provision",
Description: "Deploy missing diagnostic tools (rg, jq, yq) to ~/.shellguard/bin/ on the remote server. Uses SFTP over the existing SSH connection -- no outbound internet required on the remote. This is a WRITE operation: ask the operator for approval before calling this tool.",
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: false,
IdempotentHint: true,
},
}, func(ctx context.Context, _ *mcp.CallToolRequest, in ProvisionInput) (*mcp.CallToolResult, map[string]any, error) {
out, err := core.Provision(ctx, in)
return nil, out, err
})

mcp.AddTool(srv, &mcp.Tool{
Name: "download_file",
Description: fmt.Sprintf("Download a file from the remote server to the local filesystem via SFTP. "+
"Returns the local path so you can process the file with local tools. "+
"Maximum file size: %d bytes. Files are saved to %s by default. "+
"This is a WRITE operation on the local machine: ask the operator for approval before calling this tool.",
core.MaxDownloadBytes, core.DownloadDir),
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: false,
},
}, func(ctx context.Context, _ *mcp.CallToolRequest, in DownloadInput) (*mcp.CallToolResult, DownloadResult, error) {
out, err := core.DownloadFile(ctx, in)
return nil, out, err
})
if !core.DisabledTools["download_file"] {
mcp.AddTool(srv, &mcp.Tool{
Name: "download_file",
Description: fmt.Sprintf("Download a file from the remote server to the local filesystem via SFTP. "+
"Returns the local path so you can process the file with local tools. "+
"Maximum file size: %d bytes. Files are saved to %s by default. "+
"This is a WRITE operation on the local machine: ask the operator for approval before calling this tool.",
core.MaxDownloadBytes, core.DownloadDir),
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: false,
},
}, func(ctx context.Context, _ *mcp.CallToolRequest, in DownloadInput) (*mcp.CallToolResult, DownloadResult, error) {
out, err := core.DownloadFile(ctx, in)
return nil, out, err
})
}

return srv
}
Expand Down
40 changes: 40 additions & 0 deletions server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,46 @@ func TestNewMCPServerRegistersTools(t *testing.T) {
}
}

func TestNewMCPServer_DisabledTools(t *testing.T) {
ctx := context.Background()
core := NewCore(basicRegistry(), newFakeRunner(), nil,
WithDisabledTools([]string{"provision", "download_file"}),
)
s := NewMCPServer(core)
c := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
t1, t2 := mcp.NewInMemoryTransports()
ss, err := s.Connect(ctx, t1, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer func() { _ = ss.Close() }()
cs, err := c.Connect(ctx, t2, nil)
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer func() { _ = cs.Close() }()

found := map[string]*mcp.Tool{}
for tool, err := range cs.Tools(ctx, nil) {
if err != nil {
t.Fatalf("tools iterator error: %v", err)
}
found[tool.Name] = tool
}

for _, name := range []string{"provision", "download_file"} {
if _, ok := found[name]; ok {
t.Fatalf("tool %q should be disabled but is registered", name)
}
}
// Should still have connect, execute, disconnect, sleep
for _, name := range []string{"connect", "execute", "disconnect", "sleep"} {
if _, ok := found[name]; !ok {
t.Fatalf("expected tool %q to be registered", name)
}
}
}

func TestNewCoreAcceptsLogger(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
Expand Down
3 changes: 3 additions & 0 deletions shellguard.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ func New(cfg Config) (*server.Core, error) {
if userCfg.MaxSleepSeconds != nil {
coreOpts = append(coreOpts, server.WithMaxSleepSeconds(*userCfg.MaxSleepSeconds))
}
if len(userCfg.DisabledTools) > 0 {
coreOpts = append(coreOpts, server.WithDisabledTools(userCfg.DisabledTools))
}

return server.NewCore(registry, runner, cfg.Logger, coreOpts...), nil
}
Expand Down