-
Notifications
You must be signed in to change notification settings - Fork 22
container_shell: isolated network by default, plus optional network transport #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
anticomputer
wants to merge
9
commits into
main
Choose a base branch
from
container-shell-network-isolation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
0ead3e8
Default container_shell to an isolated network with opt-in networking
anticomputer 5e201ec
Document container_shell env vars and network-mode selection
anticomputer 065b75b
Key persistent containers on network mode to preserve isolation
anticomputer 900131e
Avoid accumulating atexit handlers when reloading in tests
anticomputer ea8cc12
Add optional network transport to container_shell MCP server
anticomputer 3e6d64b
Rename the container shell tool to container_shell_exec
anticomputer afa2a3c
Add a remote container shell toolbox
anticomputer e4a1d71
Defer CONTAINER_SHELL_PORT parsing to network transport startup
anticomputer adab2d4
Fall back to the default host when CONTAINER_SHELL_HOST is blank
anticomputer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||||||||||
| ``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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Something like this? |
||||||||||
|
|
||||||||||
| 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 | ||||||||||
|
|
@@ -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" | ||||||||||
|
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 | ||||||||||
|
|
@@ -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] | ||||||||||
|
|
@@ -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: | ||||||||||
|
anticomputer marked this conversation as resolved.
|
||||||||||
| cmd.append("--rm") | ||||||||||
| if CONTAINER_WORKSPACE: | ||||||||||
|
|
@@ -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, | ||||||||||
|
|
@@ -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() | ||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
src/seclab_taskflows/toolboxes/container_shell_remote.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)