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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ substitution. All of this is done while retaining the convenience of working in
an IDE like VS Code.

All container traffic is routed through a transparent
[mitmproxy](https://mitmproxy.org/) via WireGuard, capturing HTTP/S, DNS, and
all other TCP/UDP traffic without per-tool proxy configuration. A
straightforward allow/deny list-based engine controls which network requests go
through, and a secret substitution system injects credentials at the proxy level
so the container never sees real values.
[mitmproxy](https://mitmproxy.org/) via WireGuard, without per-tool proxy
configuration. A straightforward allow/deny list-based engine controls which
HTTP/S requests and DNS queries go through; everything else — raw TCP and UDP,
which carries no hostname to match a rule against — is dropped. A secret
substitution system injects credentials at the proxy level so the container
never sees real values.

> Sandcat is part of [Visdom](https://virtuslab.com/services/visdom),
> VirtusLab's AI-native SDLC platform.
Expand Down
7 changes: 4 additions & 3 deletions cli/lib/agents.bash
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,9 @@ EOF
# Returns mitmproxy --set flags that affect streaming-body handling.
#
# Cursor's API uses Connect/HTTP-2 streaming for agent calls. Mitmproxy needs
# stream_large_bodies (don't buffer >1MB), connection_strategy=lazy, anticomp,
# and a long read timeout to keep those streams stable.
# stream_large_bodies (don't buffer >1MB), anticomp, and a long read timeout to
# keep those streams stable. connection_strategy=lazy, which these streams also
# need, is on the base command for every agent (see compose-proxy.yml).
#
# Claude's traffic is plain JSON request/response, so leaving the body
# buffered means _substitute_secrets in the addon can run a content-based
Expand All @@ -436,7 +437,7 @@ sct_agent_mitm_streaming_flags() {
local agent=$1
case "$agent" in
cursor)
echo "--set stream_large_bodies=1m --set connection_strategy=lazy --set anticomp=true --set timeout_read=300"
echo "--set stream_large_bodies=1m --set anticomp=true --set timeout_read=300"
;;
claude|codex|copilot|*)
echo ""
Expand Down
12 changes: 11 additions & 1 deletion cli/templates/devcontainer/sandcat/compose-proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,17 @@ services:
# the command) so docker-entrypoint.sh still sees `mitmweb` as $1 and drops
# privileges to the mitmproxy user.
entrypoint: ["/bin/sh", "-c", "chown -R mitmproxy:mitmproxy /mitmproxy-public && rm -f /home/mitmproxy/.mitmproxy/dns.conf /mitmproxy-public/mitmproxy-ca-cert.pem && (while [ ! -f /home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem ]; do sleep 1; done; cp /home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem /mitmproxy-public/mitmproxy-ca-cert.pem.tmp && mv /mitmproxy-public/mitmproxy-ca-cert.pem.tmp /mitmproxy-public/mitmproxy-ca-cert.pem) & exec docker-entrypoint.sh \"$@\"", "sh"]
command: mitmweb --mode wireguard --web-host 0.0.0.0 --set web_password=mitmproxy --set http2=__MITM_HTTP2__ __AGENT_MITM_STREAMING_FLAGS__ -s /scripts/__AGENT_MITM_ADDON__
# connection_strategy=lazy defers the upstream connection until a layer asks
# for it. Without it mitmproxy dials the destination before next_layer runs,
# so a flow the addon is about to deny has already sent a SYN — a signalling
# channel out of the sandbox even though no payload is relayed.
#
# rawtcp=false closes the one raw relay the addon's next_layer hook cannot
# reach: on an HTTP `101 Switching Protocols` with a non-WebSocket upgrade,
# mitmproxy builds a TCPLayer child directly and emits no next_layer hook,
# so a request to an allowed host turns into an unfiltered byte pipe. With
# rawtcp off mitmproxy closes the connection instead.
command: mitmweb --mode wireguard --web-host 0.0.0.0 --set web_password=mitmproxy --set connection_strategy=lazy --set rawtcp=false --set http2=__MITM_HTTP2__ __AGENT_MITM_STREAMING_FLAGS__ -s /scripts/__AGENT_MITM_ADDON__
ports:
- "8081" # mitmweb UI; host port assigned dynamically to avoid conflicts
volumes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
from fnmatch import fnmatch

from mitmproxy import ctx, dns, http
from mitmproxy.addons.next_layer import NextLayer
from mitmproxy.proxy import commands as proxy_commands
from mitmproxy.proxy import events as proxy_events
from mitmproxy.proxy import layer as proxy_layer
from mitmproxy.proxy.layers import RawQuicLayer, TCPLayer, UDPLayer

_VALID_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# RFC-1123 label / RFC-2181 length. Trailing dot is stripped before validation
Expand Down Expand Up @@ -199,6 +204,22 @@ def _pass_cli_session_is_pat(stdout: str) -> bool:
return bool(_PAT_SESSION_MARKER.search(stdout or ""))


# The layers mitmproxy falls back to when it cannot parse a flow as HTTP or
# DNS. They copy bytes between client and server verbatim and emit no
# request/dns_request hook, so the allow/deny rules never see them.
RAW_RELAY_LAYERS = (TCPLayer, UDPLayer, RawQuicLayer)


class DenyLayer(proxy_layer.Layer):
"""Hangs up on the client instead of relaying, without ever dialling the
server. Used in place of a raw relay layer — see ``SandcatAddon.next_layer``.
"""

def _handle_event(self, event):
if isinstance(event, proxy_events.Start):
yield proxy_commands.CloseConnection(self.context.client)


class SandcatAddon:
"""Base sandcat addon: network policy + secret substitution."""

Expand All @@ -209,6 +230,9 @@ def __init__(self):
self.dns_servers: list[str] = [] # custom upstream DNS for wg-client
self.debug_enabled = False # subclasses may flip this in _on_settings_merged
self._pass_cli_logged_in = False # True only after a successful pass-cli login
# Our own copy of mitmproxy's layer chooser, so next_layer() below can
# see what mitmproxy would pick and veto it. See next_layer().
self._layer_chooser = NextLayer()

# ------------------------------------------------------------------ load

Expand Down Expand Up @@ -1057,6 +1081,50 @@ def _substitute_secrets(self, flow: http.HTTPFlow):

# -------------------------------------------------------------- handlers

def configure(self, updated):
# Keeps _layer_chooser's compiled host patterns in sync with the
# options mitmproxy's own NextLayer instance reads.
self._layer_chooser.configure(updated)

def next_layer(self, nextlayer: proxy_layer.NextLayer):
"""Deny anything that would be relayed as raw bytes.

The allow/deny rules key on a hostname, which only HTTP and DNS flows
carry; a raw TCP or UDP flow knows nothing but an address, so there is
nothing to match and no hook to match it in. Left alone, mitmproxy
would relay such a flow straight through and a process in the agent
container could reach any address and port it likes.

Script addons run before mitmproxy's own NextLayer (ScriptLoader sits
earlier in the default addon chain), and NextLayer skips a decision
another addon already made. So we ask our own chooser first and either
veto what it picked or let it stand — never hand the decision back,
which would let a second, unexamined choice run in its place.

mitmproxy swallows an addon hook's exception and carries on down the
chain, so anything raised here has to leave the flow denied rather
than half-decided: the chooser runs inside a try, and the assignment
happens before the log line.
"""
try:
self._layer_chooser.next_layer(nextlayer)
# Left as None when the chooser wants more data; the decision is
# retried on the next chunk, so there is nothing to veto yet.
is_raw = isinstance(nextlayer.layer, RAW_RELAY_LAYERS)
except Exception:
logger.exception("Layer choice failed — denying the flow")
is_raw = True

if not is_raw:
return

nextlayer.layer = DenyLayer(nextlayer.context)
logger.warning(
"Network deny (not HTTP or DNS): "
f"{nextlayer.context.client.transport_protocol} "
f"{nextlayer.context.server.address}"
)

def request(self, flow: http.HTTPFlow):
method = flow.request.method
host = flow.request.pretty_host
Expand Down
3 changes: 2 additions & 1 deletion cli/test/agents/agents.bats
Original file line number Diff line number Diff line change
Expand Up @@ -490,9 +490,10 @@ setup() {
@test "sct_agent_mitm_streaming_flags: cursor returns streaming flags" {
run sct_agent_mitm_streaming_flags cursor
assert_output --partial "stream_large_bodies=1m"
assert_output --partial "connection_strategy=lazy"
assert_output --partial "anticomp=true"
assert_output --partial "timeout_read=300"
# Lives on the base command for every agent instead.
refute_output --partial "connection_strategy"
}

@test "sct_agent_mitm_streaming_flags: claude returns empty" {
Expand Down
4 changes: 2 additions & 2 deletions cli/test/composefile/composefile.bats
Original file line number Diff line number Diff line change
Expand Up @@ -509,15 +509,15 @@ YAML
cat >"$proxy_compose" <<'YAML'
services:
mitmproxy:
command: mitmweb --mode wireguard --set http2=true --set stream_large_bodies=1m --set connection_strategy=lazy --set anticomp=true --set timeout_read=300 -s /scripts/mitmproxy_addon_cursor.py
command: mitmweb --mode wireguard --set connection_strategy=lazy --set rawtcp=false --set http2=true --set stream_large_bodies=1m --set anticomp=true --set timeout_read=300 -s /scripts/mitmproxy_addon_cursor.py
ports:
- "8081"
YAML

set_proxy_tui_mode "$proxy_compose"

run yq -r '.services.mitmproxy.command' "$proxy_compose"
assert_output "mitmdump --mode wireguard --set http2=true --set stream_large_bodies=1m --set connection_strategy=lazy --set anticomp=true --set timeout_read=300 -s /scripts/mitmproxy_addon_cursor.py"
assert_output "mitmdump --mode wireguard --set connection_strategy=lazy --set rawtcp=false --set http2=true --set stream_large_bodies=1m --set anticomp=true --set timeout_read=300 -s /scripts/mitmproxy_addon_cursor.py"
yq -e '.services.mitmproxy.command | contains("/scripts/mitmproxy_addon_cursor.py")' "$proxy_compose"
}

Expand Down
15 changes: 12 additions & 3 deletions cli/test/init/extensions.bats
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,11 @@ teardown() {
run grep 'stream_large_bodies=1m' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure

# connection_strategy=lazy is not agent-specific — it is on the base
# command so a denied flow never reaches the destination (see
# compose-proxy.yml).
run grep 'connection_strategy=lazy' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure
assert_success

run grep 'anticomp=true' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure
Expand Down Expand Up @@ -219,8 +222,11 @@ teardown() {
run grep 'stream_large_bodies=1m' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure

# connection_strategy=lazy is not agent-specific — it is on the base
# command so a denied flow never reaches the destination (see
# compose-proxy.yml).
run grep 'connection_strategy=lazy' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure
assert_success

run grep 'anticomp=true' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure
Expand Down Expand Up @@ -255,8 +261,11 @@ teardown() {
run grep 'stream_large_bodies=1m' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure

# connection_strategy=lazy is not agent-specific — it is on the base
# command so a denied flow never reaches the destination (see
# compose-proxy.yml).
run grep 'connection_strategy=lazy' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure
assert_success

run grep 'anticomp=true' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure
Expand Down
141 changes: 141 additions & 0 deletions cli/test/mitmproxy/test_mitmproxy_addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,83 @@ def make(status, body, headers):
_http.HTTPFlow = type("HTTPFlow", (), {})
_http.Response = _Response


# --- proxy layer stubs, for the next_layer hook ----------------------------
# Names only: the addon compares the layer mitmproxy picked against these
# classes, so identity is all the tests need.
#
# These stubs cannot notice mitmproxy renaming, moving or adding a relay layer
# — the version pin in cli/lib/constants.bash is what holds that end. A bump
# needs mitmproxy's next_layer.py re-read against RAW_RELAY_LAYERS.


class _Layer:
def __init__(self, context):
self.context = context


class _Start:
pass


class _CloseConnection:
def __init__(self, connection):
self.connection = connection


class _MitmNextLayer:
"""Stand-in for mitmproxy's layer chooser.

Tests set ``picks`` to the layer class mitmproxy would settle on, or to
None for "needs more data".
"""

picks = None

def configure(self, updated):
self.configured = updated

def next_layer(self, nextlayer):
nextlayer.layer = self.picks(nextlayer.context) if self.picks else None


_proxy = types.ModuleType("mitmproxy.proxy")
_proxy_commands = types.ModuleType("mitmproxy.proxy.commands")
_proxy_events = types.ModuleType("mitmproxy.proxy.events")
_proxy_layer = types.ModuleType("mitmproxy.proxy.layer")
_proxy_layers = types.ModuleType("mitmproxy.proxy.layers")
_addons = types.ModuleType("mitmproxy.addons")
_addons_next_layer = types.ModuleType("mitmproxy.addons.next_layer")

_proxy_commands.CloseConnection = _CloseConnection
_proxy_events.Start = _Start
_proxy_layer.Layer = _Layer
_proxy_layer.NextLayer = type("NextLayer", (), {})
for _name in ("TCPLayer", "UDPLayer", "RawQuicLayer", "HttpLayer", "DNSLayer"):
setattr(_proxy_layers, _name, type(_name, (_Layer,), {}))
_addons_next_layer.NextLayer = _MitmNextLayer

sys.modules["mitmproxy"] = types.ModuleType("mitmproxy")
sys.modules["mitmproxy.ctx"] = _ctx
sys.modules["mitmproxy.http"] = _http
sys.modules["mitmproxy.dns"] = _dns
sys.modules["mitmproxy.proxy"] = _proxy
sys.modules["mitmproxy.proxy.commands"] = _proxy_commands
sys.modules["mitmproxy.proxy.events"] = _proxy_events
sys.modules["mitmproxy.proxy.layer"] = _proxy_layer
sys.modules["mitmproxy.proxy.layers"] = _proxy_layers
sys.modules["mitmproxy.addons"] = _addons
sys.modules["mitmproxy.addons.next_layer"] = _addons_next_layer
sys.modules["mitmproxy"].ctx = _ctx
sys.modules["mitmproxy"].http = _http
sys.modules["mitmproxy"].dns = _dns
sys.modules["mitmproxy"].proxy = _proxy
sys.modules["mitmproxy"].addons = _addons
_proxy.commands = _proxy_commands
_proxy.events = _proxy_events
_proxy.layer = _proxy_layer
_proxy.layers = _proxy_layers
_addons.next_layer = _addons_next_layer

# Allow importing the addon modules from the templates directory.
_SCRIPTS_DIR = str(
Expand Down Expand Up @@ -914,6 +984,77 @@ def test_dns_trailing_dot_allowed(self, addon_cls):
assert flow.response is None


# ---------------------------------------------------------------------------
# Raw TCP/UDP flows — denied before they reach the generic relay layers.
# ---------------------------------------------------------------------------

def _make_next_layer():
nextlayer = MagicMock()
nextlayer.layer = None
nextlayer.context.client.transport_protocol = "tcp"
nextlayer.context.server.address = ("203.0.113.5", 4444)
return nextlayer


@pytest.mark.parametrize("addon_cls", ADDONS)
class TestRawFlowDenial:
@pytest.mark.parametrize("raw_layer", ["TCPLayer", "UDPLayer", "RawQuicLayer"])
def test_raw_relay_layers_are_replaced(self, addon_cls, raw_layer):
addon = addon_cls()
addon._layer_chooser.picks = getattr(_proxy_layers, raw_layer)
nextlayer = _make_next_layer()

addon.next_layer(nextlayer)

assert isinstance(nextlayer.layer, common.DenyLayer)

@pytest.mark.parametrize("allowed_layer", ["HttpLayer", "DNSLayer"])
def test_http_and_dns_layers_are_left_alone(self, addon_cls, allowed_layer):
addon = addon_cls()
picked = getattr(_proxy_layers, allowed_layer)
addon._layer_chooser.picks = picked
nextlayer = _make_next_layer()

addon.next_layer(nextlayer)

assert isinstance(nextlayer.layer, picked)

def test_a_chooser_that_raises_denies_the_flow(self, addon_cls):
addon = addon_cls()
addon._layer_chooser.next_layer = MagicMock(side_effect=RuntimeError("boom"))
nextlayer = _make_next_layer()

addon.next_layer(nextlayer)

assert isinstance(nextlayer.layer, common.DenyLayer)

def test_undecided_layer_stays_undecided(self, addon_cls):
addon = addon_cls()
addon._layer_chooser.picks = None
nextlayer = _make_next_layer()

addon.next_layer(nextlayer)

assert nextlayer.layer is None

def test_configure_reaches_the_layer_chooser(self, addon_cls):
addon = addon_cls()

addon.configure({"tcp_hosts"})

assert addon._layer_chooser.configured == {"tcp_hosts"}


def test_deny_layer_closes_the_client_without_dialling_the_server():
context = MagicMock()

issued = list(common.DenyLayer(context)._handle_event(_Start()))

assert len(issued) == 1
assert isinstance(issued[0], _CloseConnection)
assert issued[0].connection is context.client


# ---------------------------------------------------------------------------
# Config loading — exercises the shared load() + write paths.
# ---------------------------------------------------------------------------
Expand Down
5 changes: 3 additions & 2 deletions docs/agents/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ Cursor CLI support is available via `sandcat init --agent cursor`.
- **Proxy command defaults tuned for Cursor.** The generated proxy config uses
the Cursor addon and keeps mitmproxy HTTP/2 enabled (`http2=true`) (plus
streaming-safe mitmproxy
flags such as `stream_large_bodies=1m`, `connection_strategy=lazy`,
`anticomp=true`, and `timeout_read=300`).
flags such as `stream_large_bodies=1m`, `anticomp=true`, and
`timeout_read=300`). `connection_strategy=lazy`, which these streams also
need, is on the base proxy command for every agent.

Those streaming-safe flags are **Cursor-only** — they are intentionally
omitted on the Claude path (`sct_agent_mitm_streaming_flags`). With
Expand Down
Loading
Loading