Skip to content
Open
108 changes: 101 additions & 7 deletions src/seclab_taskflows/mcp_servers/container_shell.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
# SPDX-FileCopyrightText: GitHub, Inc.
# SPDX-License-Identifier: MIT

"""MCP server that runs shell commands inside a managed Docker container.

Configuration is read from the process environment (set per toolbox in the
toolbox YAML's ``server_params.env`` block):

- ``CONTAINER_IMAGE`` — image to run (required).
- ``CONTAINER_WORKSPACE`` — host path bind-mounted at ``/workspace`` (optional).
- ``CONTAINER_TIMEOUT`` — default per-command timeout in seconds (default 30).
- ``CONTAINER_PERSIST`` — reuse a deterministic container across runs when truthy.
- ``CONTAINER_PERSIST_KEY`` — extra key to distinguish persistent containers.
- ``CONTAINER_NETWORK`` — Docker network mode for the container. Defaults to

@p- p- Jul 16, 2026

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.

Suggested change
- ``CONTAINER_NETWORK``Docker network mode for the container. Defaults to
- ``CONTAINER_NETWORK``Docker network for the container. Defaults to

I'd remove "mode", since it's more than just a mode. (allows custom network names as the following description says)

(if we'd only wanted to support the mode, we should rename the env var to "CONTAINER_NETWORK_MODE" and validate the given values with an enum, but I don't think we should do that)

``none`` so the container is egress-locked. Set it to ``bridge``, ``host``, or
a user-defined network to enable networking.

Selecting a network mode from a toolbox: the agent passes only the toolbox's
declared ``env`` entries to this server, so a network mode is selectable at run
time only if the toolbox exposes the knob. To let callers opt in, add a
passthrough line to the toolbox ``env`` block, e.g.::
Comment on lines +20 to +21

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.

Suggested change
time only if the toolbox exposes the knob. To let callers opt in, add a
passthrough line to the toolbox ``env`` block, e.g.::
time only if the toolbox exposes the knob. To let callers opt in, add an
environment variable passthrough line to the toolbox ``env`` block, e.g.::

Something like this?
passthrough as a standalone term could also be interpreted as being network related.


CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', 'none') }}"

A toolbox that needs networking by default (e.g. recon tooling) can use
``'bridge'`` as the template default instead. An empty or unset value always
falls back to ``none``, so isolation cannot be disabled by a blank variable.

Transport:

- ``CONTAINER_SHELL_TRANSPORT`` — MCP transport. Defaults to ``stdio`` for the
standard local case where the agent launches this server as a subprocess. Set
it to ``http``, ``streamable-http``, or ``sse`` to run as a network-accessible
MCP server that a remote agent can connect to.
- ``CONTAINER_SHELL_HOST`` / ``CONTAINER_SHELL_PORT`` — bind address for the
network transports (defaults ``127.0.0.1`` / ``8080``); ignored for ``stdio``.

Which Docker daemon this server drives is orthogonal to the transport: the
docker CLI honours ``DOCKER_HOST`` from the environment, so pointing this server
at a specific (e.g. dedicated, isolated) daemon needs no code change here.
"""

import atexit
import hashlib
import json
Expand Down Expand Up @@ -30,6 +69,25 @@
CONTAINER_TIMEOUT = int(os.environ.get("CONTAINER_TIMEOUT", "30"))
CONTAINER_PERSIST = os.environ.get("CONTAINER_PERSIST", "").lower() in ("1", "true", "yes")
CONTAINER_PERSIST_KEY = os.environ.get("CONTAINER_PERSIST_KEY", "")
# Docker network mode for the container. Defaults to "none" so containers are
# egress-locked (no network access) unless a caller explicitly opts in by
# setting CONTAINER_NETWORK to a network name such as "bridge", "host", or a
# user-defined network. An empty or whitespace-only value falls back to "none"
# so the isolation default cannot be silently disabled by an unset variable.
CONTAINER_NETWORK = os.environ.get("CONTAINER_NETWORK", "none").strip() or "none"
Comment thread
anticomputer marked this conversation as resolved.
# MCP transport selection. Defaults to "stdio" for the standard local case
# where the agent launches this server as a subprocess. Set
# CONTAINER_SHELL_TRANSPORT to "http", "streamable-http", or "sse" to run as a
# network-accessible server (for example, an isolated sidecar reached by a
# remote agent); CONTAINER_SHELL_HOST/PORT control the bind address for those
# transports and are ignored for stdio.
CONTAINER_SHELL_TRANSPORT = os.environ.get("CONTAINER_SHELL_TRANSPORT", "stdio").strip() or "stdio"
CONTAINER_SHELL_HOST = os.environ.get("CONTAINER_SHELL_HOST", "127.0.0.1").strip() or "127.0.0.1"
# Kept as a raw string and parsed to an int lazily in _run_server() so an
# invalid value only fails when a network transport is actually selected, not at
# import time under the default stdio transport (where host/port are ignored).
CONTAINER_SHELL_PORT = os.environ.get("CONTAINER_SHELL_PORT", "8080")
_SUPPORTED_TRANSPORTS = ("stdio", "http", "streamable-http", "sse")

_DEFAULT_WORKDIR = "/workspace"
_DOCKER_TIMEOUT = 30
Expand All @@ -38,11 +96,15 @@
def _persistent_name() -> str:
"""Derive a deterministic container name from the image for reuse across tasks.

Incorporates a hash of the full image reference (and optional
CONTAINER_PERSIST_KEY) to avoid collisions between long image names that
share a common prefix, or between independent runs of the same image.
Incorporates a hash of the full image reference, the configured network
mode, and an optional CONTAINER_PERSIST_KEY. Including the network mode
ensures a run configured for one network (e.g. the default "none") never
reuses a persistent container that was created with a different, more
permissive network (e.g. "bridge"), which would otherwise silently
re-enable egress. The hash also avoids collisions between long image names
that share a common prefix, or between independent runs of the same image.
"""
key_material = CONTAINER_IMAGE
key_material = f"{CONTAINER_IMAGE}:net={CONTAINER_NETWORK}"
if CONTAINER_PERSIST_KEY:
key_material += f":{CONTAINER_PERSIST_KEY}"
digest = hashlib.sha256(key_material.encode()).hexdigest()[:12]
Expand Down Expand Up @@ -106,7 +168,7 @@ def _start_container() -> str:
else:
name = f"seclab-shell-{uuid.uuid4().hex[:8]}"

cmd = ["docker", "run", "-d", "--name", name]
cmd = ["docker", "run", "-d", "--name", name, "--network", CONTAINER_NETWORK]
if not CONTAINER_PERSIST:
Comment thread
anticomputer marked this conversation as resolved.
cmd.append("--rm")
if CONTAINER_WORKSPACE:
Expand Down Expand Up @@ -149,7 +211,7 @@ def _stop_container() -> None:


@mcp.tool()
def shell_exec(
def container_shell_exec(
command: Annotated[str, Field(description="Shell command to execute inside the container")],
timeout: Annotated[int, Field(description="Timeout in seconds")] = CONTAINER_TIMEOUT,
workdir: Annotated[str, Field(description="Working directory inside the container")] = _DEFAULT_WORKDIR,
Expand All @@ -176,5 +238,37 @@ def shell_exec(
return output


def _run_server() -> None:
"""Run the MCP server using the configured transport.

Defaults to stdio (local subprocess use). When CONTAINER_SHELL_TRANSPORT
selects a network transport, the server binds CONTAINER_SHELL_HOST:PORT so a
remote agent can reach it.
"""
if CONTAINER_SHELL_TRANSPORT not in _SUPPORTED_TRANSPORTS:
msg = (
f"Unsupported CONTAINER_SHELL_TRANSPORT {CONTAINER_SHELL_TRANSPORT!r}; "
f"expected one of {', '.join(_SUPPORTED_TRANSPORTS)}"
)
raise ValueError(msg)
if CONTAINER_SHELL_TRANSPORT == "stdio":
mcp.run(show_banner=False)
else:
try:
port = int(CONTAINER_SHELL_PORT)
except (TypeError, ValueError) as exc:
msg = (
f"Invalid CONTAINER_SHELL_PORT {CONTAINER_SHELL_PORT!r}; "
f"expected an integer for transport {CONTAINER_SHELL_TRANSPORT!r}"
)
raise ValueError(msg) from exc
mcp.run(
transport=CONTAINER_SHELL_TRANSPORT,
host=CONTAINER_SHELL_HOST,
port=port,
show_banner=False,
)


if __name__ == "__main__":
mcp.run(show_banner=False)
_run_server()
8 changes: 4 additions & 4 deletions src/seclab_taskflows/taskflows/container_shell/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Container Shell Taskflows

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.

Comment on lines 3 to 6
Four container profiles are provided. Each has its own Dockerfile, toolbox
Expand Down Expand Up @@ -117,17 +117,17 @@ taskflow:
Analyse the binary at /workspace/target.elf using static analysis only.
```

`shell_exec` requires user confirmation by default (`confirm: [shell_exec]` in
`container_shell_exec` requires user confirmation by default (`confirm: [container_shell_exec]` in
all toolbox YAMLs). Pass `headless: true` at the task level to skip
confirmation in automated pipelines.

## Notes

- The container is shared across all `shell_exec` calls within a single
- The container is shared across all `container_shell_exec` calls within a single
taskflow run. State (files written, processes started) persists between calls.
- `--rm` is set on `docker run`, so the container is removed automatically when
stopped.
- The container name follows the pattern `seclab-shell-<8 hex chars>` and is
visible in `docker ps`.
- If `docker run` fails (e.g. image not found), `shell_exec` returns an error
- If `docker run` fails (e.g. image not found), `container_shell_exec` returns an error
string rather than raising, so the agent can report the problem cleanly.
5 changes: 3 additions & 2 deletions src/seclab_taskflows/toolboxes/container_shell_base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ server_params:
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}"
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', required=False) }}"
LOG_DIR: "{{ env('LOG_DIR') }}"

confirm:
- shell_exec
- container_shell_exec

server_prompt: |
## Container Shell (base)
You have access to an isolated Docker container. Use `shell_exec` to run commands.
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 +26 to 28
Available tools in this container:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ server_params:
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}"
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', required=False) }}"
LOG_DIR: "{{ env('LOG_DIR') }}"

confirm:
- shell_exec
- container_shell_exec

server_prompt: |
## Container Shell (malware analysis)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ server_params:
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}"
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', 'bridge') }}"
LOG_DIR: "{{ env('LOG_DIR') }}"

confirm:
- shell_exec
- container_shell_exec

server_prompt: |
## Container Shell (network analysis)
Expand Down
44 changes: 44 additions & 0 deletions src/seclab_taskflows/toolboxes/container_shell_remote.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# SPDX-FileCopyrightText: GitHub, Inc.
# SPDX-License-Identifier: MIT

seclab-taskflow-agent:
filetype: toolbox
version: "1.0"

# Remote variant of the container shell. Instead of launching container_shell
# as a local stdio subprocess (which needs a Docker socket in the agent
# environment), this connects to a container_shell MCP server that is already
# running elsewhere over Streamable HTTP. The server owns the Docker daemon; the
# agent only speaks MCP to it. This is what lets an AWF-isolated agent use
# containerized shell execution without ever being handed a Docker socket: the
# server runs outside the sandbox and is reached over a single allowlisted host
# service port.
#
# CONTAINER_SHELL_URL must point at the running server's Streamable HTTP
# endpoint, for example http://host.docker.internal:8765/mcp/.
server_params:
kind: streamable
url: "{{ env('CONTAINER_SHELL_URL') }}"

confirm:
- container_shell_exec

server_prompt: |
## Container Shell (remote)
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 +28 to +31
Available tools in this container:
- bash, coreutils (ls, cat, grep, find, sed, awk, sort, uniq, wc, ...)
- file: identify file type by magic bytes
- strings: extract printable strings from binary files
- objdump: disassemble and dump object/binary files
- readelf: display ELF binary structure
- nm: list symbols from object files
- xxd / hexdump: hex inspection
- python3: scripting
- curl / wget: HTTP requests
- git: version control

All commands run inside the container. Output includes stdout, stderr, and exit code.
3 changes: 2 additions & 1 deletion src/seclab_taskflows/toolboxes/container_shell_sast.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ server_params:
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}"
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', required=False) }}"
LOG_DIR: "{{ env('LOG_DIR') }}"

confirm:
- shell_exec
- container_shell_exec

server_prompt: |
## Container Shell (SAST)
Expand Down
Loading