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: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
**/bin/
**/obj/
.git/
benchmarks/polly/results/
tests/Sandbox.Fixtures/
.vs/
*.user
.claude/
TestResults/
artifacts/
*.nupkg
.env*
**/__pycache__/
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,17 @@ updates:
labels:
- "dependencies"
- "github-actions"

- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 3
ignore:
# The server targets net10.0: an SDK major bump (e.g. 11.0) has no 10.0 runtime. Move majors by hand.
- dependency-name: "mcr.microsoft.com/dotnet/sdk"
update-types: ["version-update:semver-major"]
labels:
- "dependencies"
- "docker"
92 changes: 92 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,95 @@ jobs:
name: test-results-${{ matrix.os }}
path: TestResults/**/*.trx
retention-days: 7

sandbox:
name: container sandbox containment
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6

- name: Build image
run: docker build -t dotnetdevmcp:ci .

- name: Smoke test (container --help)
run: docker run --rm dotnetdevmcp:ci --help

- name: Smoke test (stdio handshake + tools/list)
shell: bash
run: |
count_tools() {
grep '"id":2' "$1" | grep -o '"name":"' | wc -l
}

{ printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'; sleep 5; } \
| docker run -i --rm dotnetdevmcp:ci --log-level Warning 2>/dev/null > smoke-container.out || true
count=$(count_tools smoke-container.out)
echo "tools listed (container, default): $count"
test "$count" -eq 37

# Native-Linux form (see README/SECURITY.md "Run it in a container"):
# `--user "$(id -u):$(id -g)"` matches the container to the runner's own
# uid, since ubuntu-latest is a real Linux host where the checked-out
# repo is owned by that uid, not remapped the way Docker Desktop's VM
# does it. This proves that documented path actually works, rather than
# papering over the uid 10001 vs. runner-uid mismatch with a chmod.
- name: Restore Sandbox.Fixtures (network needed)
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-e NUGET_PACKAGES=/nuget \
-v "$PWD/tests/Sandbox.Fixtures:/src" \
-v nuget:/nuget \
--entrypoint dotnet \
dotnetdevmcp:ci restore /src

- name: Run sandbox containment tests (with sandbox flags)
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-e NUGET_PACKAGES=/nuget \
--network none \
--memory 8g \
--memory-swap 8g \
--pids-limit 512 \
--cap-drop ALL \
--security-opt no-new-privileges \
-v "$PWD/tests/Sandbox.Fixtures:/src" \
-v nuget:/nuget:ro \
--entrypoint dotnet \
dotnetdevmcp:ci test /src --no-restore -- RunConfiguration.TreatNoTestsAsError=true

# Negative control: the same run, minus every sandbox flag. The fixtures
# assert things the flags themselves cause (empty capability bounding
# set, no swap, the pids cap, no network interface but loopback, only
# /src and the package cache mounted); without the flags those
# assertions should fail, which is what proves they were testing the
# flags and not trivially passing regardless of environment.
- name: Run sandbox containment tests (without sandbox flags - must fail)
run: |
set +e
docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-e NUGET_PACKAGES=/nuget \
-v "$PWD/tests/Sandbox.Fixtures:/src" \
-v nuget:/nuget \
--entrypoint dotnet \
dotnetdevmcp:ci test /src --no-restore -- RunConfiguration.TreatNoTestsAsError=true > negative.log 2>&1
code=$?
set -e
cat negative.log
# Any failure isn't enough (a build error or a bad mount also fails): exactly these six must fail, one per flag.
[ "$code" -ne 0 ] || { echo "controls did not discriminate: tests passed without the sandbox flags"; exit 1; }
grep -Eq 'Total: +7' negative.log || { echo "expected 7 tests to run"; exit 1; }
grep -Eq 'Failed: +6,' negative.log || { echo "expected exactly 6 failures"; exit 1; }
for t in CapabilityBoundingSetIsEmpty NoNewPrivsIsSet MemoryIsCappedAt8GiBWithNoSwap PidsAreCappedAt512 \
OnlyLoopbackInterfaceExists WorkspaceWritableButServerAndPackageCacheAreNot; do
grep -q "Failed Sandbox.Fixtures.SandboxControlTests.$t" negative.log || { echo "$t did not fail without its flag"; exit 1; }
done
75 changes: 75 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# syntax=docker/dockerfile:1
#
# Multi-stage build for DotNetDevMCP.
#
# The runtime stage uses the full SDK image (not aspnet/runtime) because the
# server itself shells out to `dotnet build` / `dotnet test` / `dotnet restore`
# against the caller's mounted solution - the SDK toolchain must be present
# at runtime, not just at build time.

# mcr.microsoft.com/dotnet/sdk:10.0
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:35d40304542c8689331f8cab17c65926cdf48fe711e289321d71924b230a7d29 AS build
WORKDIR /repo
COPY . .
RUN dotnet publish src/DotNetDevMCP.Server -c Release -o /app

# mcr.microsoft.com/dotnet/sdk:10.0
FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:35d40304542c8689331f8cab17c65926cdf48fe711e289321d71924b230a7d29
COPY --from=build /app /app

# Git treats a repository owned by a different uid than the process running it
# as "dubious ownership" and refuses to operate on it (CVE-2022-24765
# hardening). /src is a bind mount owned by whatever the host repo's owner is,
# almost never uid 10001, so without this the `git`-mode tools (and
# dotnet_test_affected's git diff) fail with "fatal: detected dubious
# ownership in repository at '/src'". Trusting every directory here is fine:
# the whole point of this container is to operate on whatever repo the caller
# mounted, which is already the trust boundary.
#
# Caveat this does NOT fix: a git worktree or submodule whose .git *file*
# points at a gitdir outside /src (e.g. a worktree checked out from a bare
# repo elsewhere on the host, or a submodule whose superproject isn't also
# mounted) still fails, because that gitdir path doesn't exist inside the
# container. Only self-contained repos/worktrees fully under the mounted
# directory work in git mode.
RUN git config --system --add safe.directory '*'

# Strip setuid/setgid bits from every regular file the SDK image ships (sudo,
# ping, etc.). Nothing in this server's own code needs them, and --cap-drop
# ALL already empties the capability bounding set, but removing the bits too
# means a setuid binary can't even try to regain a capability if some future
# base-image update reintroduces one. -xdev keeps this to the image's own
# filesystem layer (nothing is mounted yet at build time regardless).
RUN find / -xdev -perm /6000 -type f -exec chmod a-s {} +

# Non-root user. uid 10001 with a real home dir so `dotnet` (NuGet, MSBuild
# caches, etc.) has somewhere writable to put its own state; the mounted
# solution under /src is what the user actually controls.
#
# ~/.nuget/packages is pre-created and chowned here (not left for `dotnet` to
# create on first use) so that when a named volume is bind-mounted at that path
# (e.g. `-v nuget:/home/mcp/.nuget/packages`), Docker's first-use volume
# initialization copies this directory's mcp:mcp ownership into the volume,
# instead of creating it fresh as root because the path didn't previously exist
# in the image. That's the default (Docker Desktop / Windows / macOS) form.
#
# /nuget (mode 1777, sticky-bit world-writable, like /tmp) is a second,
# uid-agnostic package cache location for the native-Linux form documented in
# README/SECURITY.md, where the container is run with `--user "$(id -u):$(id
# -g)"` to match the host repo's owner instead of the image's built-in mcp
# user - in that form uid 10001 doesn't exist as far as the container's
# process is concerned, so ~mcp/.nuget isn't usable and NUGET_PACKAGES=/nuget
# is passed instead.
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin mcp \
&& mkdir -p /home/mcp/.nuget/packages \
&& mkdir -m 1777 /nuget \
&& chown -R mcp:mcp /home/mcp

ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1 \
DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1

WORKDIR /src
USER mcp

ENTRYPOINT ["dotnet", "/app/dotnetdevmcp.dll"]
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,36 @@ DotNetDevMCP runs as you, for an agent you trust with your code. `dotnet build`
contains, so a malicious test or `.csproj` runs with your privileges, exactly as it would in your terminal; the server adds no
sandbox. What it does guarantee: tool arguments can't smuggle extra options into `dotnet` or `git`, Roslyn edits stay inside the
solution directory, and `--clean-env` keeps secrets in environment variables away from child processes. For code you don't
trust, run the agent and the server in a container with no credentials. Don't expose `--http` beyond localhost. Details:
trust, run the agent and the server together in a container or VM with no credentials; the image below contains only the
server, which already keeps builds and tests off your machine. Don't expose `--http` beyond localhost. Details:
[SECURITY.md](https://github.com/csa7mdm/DotNetDevMCP/blob/main/SECURITY.md#security-model).

### Run it in a container

The server can run in a container with no network, capped memory and processes, no capabilities, and only your repository
mounted, so builds and tests the agent triggers can't reach the rest of your machine. Build the image (v0.3.4 is the first
release with a Dockerfile), restore once with network, then register the sandboxed server:

```bash
docker build -t dotnetdevmcp https://github.com/csa7mdm/DotNetDevMCP.git#v0.3.4
```
```bash
docker run --rm -v "${PWD}:/src" -v dotnetdevmcp-nuget:/home/mcp/.nuget/packages --memory 8g --memory-swap 8g --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges --entrypoint dotnet dotnetdevmcp restore /src/YourSolution.sln
```
```bash
claude mcp add dotnetdevmcp -- docker run -i --rm --network none -v "${PWD}:/src" -v dotnetdevmcp-nuget:/home/mcp/.nuget/packages:ro --memory 8g --memory-swap 8g --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges dotnetdevmcp --load-solution /src/YourSolution.sln
```

This is the Docker Desktop form (Windows, macOS): run it from PowerShell, WSL or macOS, or prefix each command with
`MSYS_NO_PATHCONV=1` in Git Bash. Native Linux needs a `--user` variant, and Windows repos with `core.autocrlf` need one extra
setting for git mode; both are in [SECURITY.md](https://github.com/csa7mdm/DotNetDevMCP/blob/main/SECURITY.md#run-it-in-a-container),
along with what each flag blocks and the tests that prove it.

It doesn't protect everything: the mounted repository is writable by design; only the server is contained, not the agent or
the `docker` CLI (which needs root-equivalent daemon access on Linux); restore runs the solution's own build logic with
network access; a shared package cache can carry a planted build file from one repo to another (use one volume per untrusted
repo); and Docker Desktop's boundary is a VM kernel shared by all your containers.

## Help, feedback and contributing

- **Questions, ideas, "is this a bug?"**: [Discussions](https://github.com/csa7mdm/DotNetDevMCP/discussions).
Expand Down
Loading
Loading