Skip to content

container_shell: isolated network by default, plus optional network transport#93

Open
anticomputer wants to merge 9 commits into
mainfrom
container-shell-network-isolation
Open

container_shell: isolated network by default, plus optional network transport#93
anticomputer wants to merge 9 commits into
mainfrom
container-shell-network-isolation

Conversation

@anticomputer

@anticomputer anticomputer commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The container_shell MCP server currently starts its managed Docker container on the default bridge network, so any command run through shell_exec has unrestricted outbound network access. For an analysis sandbox that runs untrusted code and processes untrusted inputs, network access should be opt-in rather than the default. This PR makes that the default and, relatedly, lets the server itself run as a network-reachable MCP endpoint so it can be deployed as an isolated sidecar.

Isolated container network by default

This change starts containers with --network none by default, so the container is egress-locked unless a caller explicitly enables networking.

  • Add a CONTAINER_NETWORK environment variable (default none) that selects the Docker network mode passed to docker run --network. Callers opt into networking by setting it to bridge, host, or a user-defined network name.
  • Fail closed: an unset, empty, or whitespace-only value falls back to none, so the isolation default cannot be silently disabled by a blank variable.
  • Expose CONTAINER_NETWORK on the container_shell toolboxes. The base, sast, and malware_analysis toolboxes inherit the isolated default; the network_analysis toolbox defaults to bridge because its recon tools (e.g. nmap) need egress.

Verified end-to-end against a real Docker daemon:

CONTAINER_NETWORK HostConfig.NetworkMode interfaces egress to 1.1.1.1
unset none lo BLOCKED
none none lo BLOCKED
bridge bridge eth0, lo REACHED

Selecting a network mode from a toolbox

The agent forwards only a toolbox's declared env entries to this server, so a network mode is run-time selectable only if the toolbox exposes the knob. A toolbox opts in with a passthrough line in its env block, e.g. CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', 'none') }}" (use 'bridge' as the default for a toolbox that should be networked by default). A toolbox that omits the line inherits the isolated none default. This is documented in the container_shell module docstring.

Optional network transport

container_shell runs only over stdio today, as a subprocess launched locally by the agent. That is the right default when the server sits next to a local Docker daemon, but it means the server cannot be run as a standalone, network-reachable MCP endpoint. This adds an opt-in transport selector so the same server can also be served over HTTP/SSE and connected to as a remote MCP server, without changing existing behavior.

  • CONTAINER_SHELL_TRANSPORT — MCP transport. Defaults to stdio (unchanged behavior). Set to http, streamable-http, or sse to serve over the network.
  • CONTAINER_SHELL_HOST / CONTAINER_SHELL_PORT — bind address for the network transports (default 127.0.0.1 / 8080); ignored for stdio.
  • A blank/whitespace CONTAINER_SHELL_TRANSPORT falls back to stdio, so the local default cannot be silently changed; an unknown transport raises a clear ValueError listing the supported values.

Which Docker daemon the server drives is orthogonal to the transport: the docker CLI already honours DOCKER_HOST from the environment, so pointing the server at a specific daemon needs no code change here.

Manual smoke test: started the server with CONTAINER_SHELL_TRANSPORT=http, confirmed it binds the configured host/port and answers an MCP initialize handshake over HTTP (serverInfo.name: "ContainerShell"), then verified the listener is released on shutdown.

Rename the tool to container_shell_exec

The shell tool is renamed from shell_exec to container_shell_exec so it is unambiguous when several MCP servers are loaded together. FastMCP derives the tool name from the function name, so the rename covers the tool definition, the confirm lists in every container shell toolbox, the tests, and the docs. Verified over MCP: list_tools against the running server returns ['container_shell_exec'].

Remote container shell toolbox

Adds container_shell_remote, a toolbox that connects to a container_shell server already running elsewhere over Streamable HTTP instead of launching it as a local stdio subprocess. The endpoint is read from CONTAINER_SHELL_URL. This is what lets a network-isolated agent use containerized shell execution without being handed a Docker socket: the server owns the daemon and is reached over a single allowlisted host service port.

This toolbox depends on GitHubSecurityLab/seclab-taskflow-agent#279, which env-templates the toolbox url so url: "{{ env('CONTAINER_SHELL_URL') }}" resolves at connect time. Verified by loading the real toolbox through the agent model and confirming the url resolves to the concrete endpoint.

Verification

Added unit tests covering the network default/opt-in/blank-fallback, persistent-container keying by network mode, the stdio transport default, blank-transport fallback, the host blank-fallback, lazy port parsing (an invalid port leaves stdio working but fails a network transport), the exact mcp.run(...) wiring for each network transport, and the unknown-transport error. Full container_shell suite passes (47 tests) and hatch fmt --linter --check is clean.

The tool rename keeps the full container_shell suite green (47 tests). The remote toolbox's url resolution is covered by the agent PR above.

Start container_shell containers with `--network none` by default so the
managed Docker container has no network access unless a caller explicitly
opts in. A new CONTAINER_NETWORK environment variable selects the docker
network mode (e.g. "bridge", "host", or a user-defined network); an unset,
empty, or whitespace-only value falls back to "none" so isolation cannot be
silently disabled.

Expose CONTAINER_NETWORK on the container_shell toolboxes. The base, SAST,
and malware-analysis toolboxes inherit the isolated default; the network
analysis toolbox defaults to "bridge" since it needs egress for recon.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Copilot AI review requested due to automatic review settings July 14, 2026 15:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the container_shell MCP server by making Docker containers start with networking disabled by default (--network none), requiring callers/toolboxes to explicitly opt into outbound connectivity via a new CONTAINER_NETWORK environment variable.

Changes:

  • Add CONTAINER_NETWORK (defaulting to none, with blank/whitespace values failing closed to none) and pass it to docker run --network.
  • Add unit tests covering default, opt-in (bridge), and unset/blank fallback behavior.
  • Plumb CONTAINER_NETWORK through the container_shell toolbox YAMLs, with network_analysis defaulting to bridge.
Show a summary per file
File Description
src/seclab_taskflows/mcp_servers/container_shell.py Introduces CONTAINER_NETWORK and applies it to docker run for default network isolation.
tests/test_container_shell.py Adds tests for default/opt-in/fallback network behavior.
src/seclab_taskflows/toolboxes/container_shell_base.yaml Exposes CONTAINER_NETWORK to the base toolbox environment.
src/seclab_taskflows/toolboxes/container_shell_sast.yaml Exposes CONTAINER_NETWORK to the SAST toolbox environment.
src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml Exposes CONTAINER_NETWORK to the malware analysis toolbox environment.
src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml Exposes CONTAINER_NETWORK with a default of bridge for network tooling.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 6/6 changed files
  • Comments generated: 3
  • Review effort level: Low

Comment thread src/seclab_taskflows/mcp_servers/container_shell.py
Comment thread tests/test_container_shell.py
Comment thread tests/test_container_shell.py Outdated
Add a module docstring listing the CONTAINER_* configuration variables and
explaining how a toolbox exposes CONTAINER_NETWORK so callers can select a
Docker network mode at run time, since the agent only forwards a toolbox's
declared env entries to the server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Copilot AI review requested due to automatic review settings July 14, 2026 17:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread src/seclab_taskflows/mcp_servers/container_shell.py
Include the configured network mode in the persistent container name hash so a
run configured for one network never reuses a persistent container created with
a different, more permissive network. Without this, a container started with
CONTAINER_NETWORK=bridge could be reused by a later run that requests the
default "none", silently re-enabling egress.

Also restore the real environment before reloading the module in the network
fallback tests so module-level configuration does not leak between tests, and
add coverage that the persistent name varies with the network mode.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Copilot AI review requested due to automatic review settings July 14, 2026 17:24
@anticomputer

Copy link
Copy Markdown
Contributor Author

Thanks for the review — both issues were valid and are addressed in 065b75b.

Persistent container reuse ignored network mode (flagged at _persistent_name and the reuse path): the configured network mode is now part of the persistent container name hash (key_material = f"{CONTAINER_IMAGE}:net={CONTAINER_NETWORK}"). A run configured for none therefore looks up a different container name than one created with bridge/host and never reuses a more-permissive container, so the isolation default holds for persistent runs too. Verified end-to-end against Docker with CONTAINER_PERSIST=true: a pre-existing bridge container (egress reachable) is not reused by a subsequent none run — a distinct container is created and egress is blocked.

step CONTAINER_NETWORK container egress to 1.1.1.1
1 bridge seclab-persist-02af9e7e5eb0 REACHED
2 none (same image, persist) seclab-persist-574436321ef4 BLOCKED

Test reload leakage (flagged on the two fallback tests): the tests now capture the real CONTAINER_NETWORK value up front and restore it before the final importlib.reload, via a _restore_env_and_reload helper, so the reloaded module reflects the actual environment rather than the monkeypatched one and nothing leaks into later tests.

Added a test_persistent_name_varies_with_network unit test. Full suite: 33 passing, hatch fmt --linter --check clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 6/6 changed files
  • Comments generated: 3
  • Review effort level: Low

Comment thread tests/test_container_shell.py Outdated
Comment thread tests/test_container_shell.py
Comment thread tests/test_container_shell.py
Reloading the container_shell module re-runs its module-level
atexit.register(_stop_container), stacking a new handler each time and causing
duplicate stop attempts at exit. Unregister the current handler before every
reload in the tests so exactly one remains.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Copilot AI review requested due to automatic review settings July 14, 2026 17:51
@anticomputer

Copy link
Copy Markdown
Contributor Author

Also addressed the second-pass note about atexit handler accumulation on reload (900131e): the tests now unregister the current _stop_container handler before every importlib.reload, so exactly one handler remains. Verified atexit._ncallbacks() stays flat across repeated dedup reloads (5 → 5) where a naive reload would accumulate (5 → 8). Suite still 33 passing, lint clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment thread tests/test_container_shell.py Outdated
Comment thread tests/test_container_shell.py Outdated
@anticomputer anticomputer marked this pull request as draft July 14, 2026 18:06
The container_shell server previously ran only over stdio as a locally
launched subprocess. Add CONTAINER_SHELL_TRANSPORT (default stdio) plus
CONTAINER_SHELL_HOST/PORT so the same server can run over http,
streamable-http, or sse as a network-accessible MCP server that a remote agent
can connect to. Blank values fall back to stdio so the local default cannot be
silently changed, and unknown transports raise a clear error.

Which Docker daemon the server drives stays controlled by DOCKER_HOST, so no
code change is needed to point it at a specific daemon.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Copilot AI review requested due to automatic review settings July 14, 2026 18:23
@anticomputer anticomputer changed the title Default container_shell to an isolated network with opt-in networking container_shell: isolated network by default, plus optional network transport Jul 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Low

Give the tool a name that is unambiguous when several MCP servers are
loaded together. The FastMCP tool name follows the function name, so the
rename covers the tool definition, the confirm lists in every container
shell toolbox, the tests, and the docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Connect to a container_shell MCP server that is already running elsewhere
over Streamable HTTP instead of launching it as a local stdio subprocess.
The endpoint comes from CONTAINER_SHELL_URL. This lets a network-isolated
agent use containerized shell execution without being handed a Docker
socket: the server owns the daemon and is reached over one allowlisted
host service port.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Copilot AI review requested due to automatic review settings July 15, 2026 20:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 8/8 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread src/seclab_taskflows/mcp_servers/container_shell.py
Parsing CONTAINER_SHELL_PORT with int() at import time made a non-integer
value raise during import and break the default stdio transport, even though
host and port are ignored for stdio. Keep the value as a raw string at import
and convert it to an int inside _run_server() only when a network transport is
selected, raising a clear error there if it is not a valid integer.

Also parameterize the blank-network fallback test to cover an explicit empty
string alongside whitespace-only input, sort the stdlib imports in the test
module, and add tests that an invalid port leaves stdio working while failing
network transport startup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Copilot AI review requested due to automatic review settings July 15, 2026 21:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment thread src/seclab_taskflows/mcp_servers/container_shell.py Outdated
Comment thread tests/test_container_shell.py
A blank or whitespace-only CONTAINER_SHELL_HOST was passed verbatim to the
network transport, which could bind unexpectedly. Strip it and fall back to
127.0.0.1 when empty, matching the fail-closed handling of CONTAINER_NETWORK
and CONTAINER_SHELL_TRANSPORT.

Also pin the transport to stdio in the invalid-port stdio test so it does not
depend on the ambient CONTAINER_SHELL_TRANSPORT value, and add a test for the
host blank fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fc0c170-07c4-4eff-b075-3e1f420c4ba3
Copilot AI review requested due to automatic review settings July 15, 2026 22:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 8/8 changed files
  • Comments generated: 3
  • Review effort level: Low

Comment on lines +26 to 28
You have access to an isolated Docker container. Use `container_shell_exec` to run commands.
The working directory is /workspace (mapped from the host workspace if configured).

Comment on lines +28 to +31
You have access to an isolated Docker container running on a remote shell
server. Use `container_shell_exec` to run commands inside it. The working
directory is /workspace (mapped from the host workspace if configured).

Comment on lines 3 to 6
Runs arbitrary CLI commands inside an isolated Docker container. One container
per MCP server process — started on the first `shell_exec` call, stopped on
per MCP server process — started on the first `container_shell_exec` call, stopped on
exit. An optional host directory is mounted at `/workspace` inside the container.

@anticomputer anticomputer marked this pull request as ready for review July 15, 2026 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants