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
62 changes: 51 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,53 @@ Each rule has:
- `host` — glob pattern via fnmatch (required)
- `method` — HTTP method to match; omit to match any method (optional)

### Network presets

Instead of listing every registry host by hand, a rule may be a **preset
reference** that expands, in place, to a predefined group of `allow` rules:

```json
{
"network": [
{"preset": "python"},
{"preset": "github"},
{"action": "allow", "host": "internal.corp.dev"}
]
}
```

Expansion preserves rule order, so first-match-wins semantics work across
presets — a `deny` rule placed before a preset shadows any host the preset
would allow. A preset entry must contain the `preset` key alone, and an
unknown preset name stops the proxy from starting (fail-loud) instead of
silently changing the policy.

Available presets (defined in `mitmproxy_addon_common.py`, versioned with
sandcat):

| Preset | Covers |
|--------|--------|
| `python` | pypi.org, files.pythonhosted.org, pypi.python.org, astral.sh (uv) |
| `node` | registry.npmjs.org, registry.yarnpkg.com, nodejs.org |
| `java` | Maven Central, Sonatype, Gradle plugin/services/downloads |
| `scala` | sbt/Typesafe repos + Maven Central (self-contained) |
| `go` | proxy.golang.org, sum.golang.org, pkg.go.dev, golang.org, google.golang.org |
| `rust` | crates.io (+index/static), static.rust-lang.org |
| `ruby` | rubygems.org (+index/api) |
| `dotnet` | api.nuget.org, globalcdn.nuget.org, nuget.org |
| `zig` | ziglang.org |
| `nix` | cache/channels/releases.nixos.org, search.devbox.sh (runtime devbox installs) |
| `vscode` | update/marketplace.visualstudio.com, *.vsassets.io, main.vscode-cdn.net |
| `jetbrains` | plugins.jetbrains.com, downloads.marketplace.jetbrains.com |
| `github` | github.com, *.github.com, *.githubusercontent.com |
| `anthropic` | *.anthropic.com, *.claude.ai, *.claude.com |
| `openai` | api.openai.com, *.openai.com |

Presets are host-only (no method restriction) — they mirror the domain-level
allowlists they are modeled on, and per-method tightening of package
registries breaks legitimate flows (e.g. `npm audit` POSTs) for marginal
gain. Add your own `deny` rules before a preset to tighten further.

### Default settings

`sandcat init` creates two settings files automatically:
Expand All @@ -788,17 +835,10 @@ Each rule has:
any host. This means the agent can read arbitrary web content, which is a
prompt injection vector.

For stricter configurations, edit the settings files to limit allowed domains.
Common additions for specific stacks:

| Stack | Domains |
|-------|---------|
| VS Code | `update.code.visualstudio.com`, `marketplace.visualstudio.com`, `*.vsassets.io`, `main.vscode-cdn.net` |
| npm | `registry.npmjs.org` |
| PyPI | `pypi.org`, `files.pythonhosted.org` |
| Rust / Cargo | `crates.io`, `static.crates.io` |
| Java / Maven | `repo.maven.apache.org`, `repo1.maven.org` |
| JetBrains | `plugins.jetbrains.com`, `downloads.marketplace.jetbrains.com` |
For stricter configurations, replace the wildcard rule with the
[network presets](#network-presets) for your stacks (plus any internal hosts
you need) — e.g. `{"preset": "python"}, {"preset": "github"}` instead of
`{"action": "allow", "host": "*", "method": "GET"}`.

### DNS filtering

Expand Down
136 changes: 135 additions & 1 deletion cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,106 @@
# stale entries from previous runs.
EXTRA_HOSTS_PATH = "/home/mitmproxy/.mitmproxy/extra_hosts"

# Predefined network allowlists (issue #2). A settings `network` entry of
# `{"preset": "<name>"}` expands, in place, to one `{"action": "allow",
# "host": <h>}` rule per host below — see _expand_network_presets. Names
# deliberately match `sandcat init --stacks` values where an ecosystem
# preset exists, plus agent/API and forge presets. Host-only on purpose
# (no method restriction): these mirror the domain-level allowlists this
# feature is modeled on, and per-method tightening of package registries
# breaks legitimate flows (e.g. npm audit POSTs) for marginal gain.
# Ecosystem presets are self-contained — `scala` repeats the Maven hosts
# instead of depending on `java` — so using one preset alone never leaves
# a hidden gap; duplicate rules from combined presets are harmless under
# first-match-wins.
NETWORK_PRESETS: dict[str, list[str]] = {
"python": [
"pypi.org",
"files.pythonhosted.org",
"pypi.python.org",
"astral.sh", # uv installer/metadata
],
"node": [
"registry.npmjs.org",
"registry.yarnpkg.com",
"nodejs.org",
],
"java": [
"repo.maven.apache.org",
"repo1.maven.org",
"central.sonatype.com",
"plugins.gradle.org",
"services.gradle.org",
"downloads.gradle.org",
"downloads.gradle-dn.com",
],
"scala": [
"repo.scala-sbt.org",
"repo.typesafe.com",
"repo1.maven.org",
"repo.maven.apache.org",
"central.sonatype.com",
],
"go": [
"proxy.golang.org",
"sum.golang.org",
"pkg.go.dev",
"golang.org",
"google.golang.org",
],
"rust": [
"crates.io",
"index.crates.io",
"static.crates.io",
"static.rust-lang.org",
],
"ruby": [
"rubygems.org",
"index.rubygems.org",
"api.rubygems.org",
],
"dotnet": [
"api.nuget.org",
"globalcdn.nuget.org",
"nuget.org",
],
"zig": [
"ziglang.org",
],
# devbox/nix — needed when installing packages at RUNTIME inside the
# agent (image build happens on the host, outside the proxy).
"nix": [
"cache.nixos.org",
"channels.nixos.org",
"releases.nixos.org",
"search.devbox.sh",
],
"vscode": [
"update.code.visualstudio.com",
"marketplace.visualstudio.com",
"*.vsassets.io",
"main.vscode-cdn.net",
],
"jetbrains": [
"plugins.jetbrains.com",
"downloads.marketplace.jetbrains.com",
],
"github": [
"github.com",
"*.github.com",
"*.githubusercontent.com",
],
"anthropic": [
"*.anthropic.com",
"*.claude.ai",
"*.claude.com",
],
"openai": [
"api.openai.com",
"*.openai.com",
],
}

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -533,9 +633,43 @@ def _normalize_secret_value(value) -> str:
# --------------------------------------------------------------- network

def _load_network_rules(self, raw_rules: list):
self.network_rules = raw_rules
self.network_rules = self._expand_network_presets(raw_rules)
ctx.log.info(f"Loaded {len(self.network_rules)} network rule(s)")

@classmethod
def _expand_network_presets(cls, raw_rules: list) -> list:
"""Expand ``{"preset": "<name>"}`` entries into their predefined rules.

Each preset expands in place, so rule order — and therefore the
top-to-bottom / first-match-wins semantics — is preserved around it.
A preset entry must carry the ``preset`` key alone: combining it with
``host``/``method``/``action`` has no defined meaning, and an unknown
preset name aborts the proxy start (RuntimeError) rather than
silently weakening or tightening the policy the user asked for.
"""
expanded: list = []
for rule in raw_rules:
if not isinstance(rule, dict) or "preset" not in rule:
expanded.append(rule)
continue
extra_keys = sorted(set(rule) - {"preset"})
if extra_keys:
raise RuntimeError(
f"network preset entry must not combine 'preset' with other "
f"keys (got extra: {extra_keys}); put additional rules on "
f"their own lines before or after the preset"
)
name = rule["preset"]
if name not in NETWORK_PRESETS:
raise RuntimeError(
f"unknown network preset {name!r}; available presets: "
f"{', '.join(sorted(NETWORK_PRESETS))}"
)
hosts = NETWORK_PRESETS[name]
expanded.extend({"action": "allow", "host": h} for h in hosts)
ctx.log.info(f"Network preset {name!r} expanded to {len(hosts)} allow rule(s)")
return expanded

# ----------------------------------------------------------- DNS servers

def _load_dns_servers(self, raw):
Expand Down
69 changes: 69 additions & 0 deletions cli/test/mitmproxy/test_mitmproxy_addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,75 @@ def test_dns_trailing_dot_stripped(self, addon_cls):
assert addon._is_request_allowed("GET", "api.github.com.") is True


# ---------------------------------------------------------------------------
# Network presets — {"preset": "<name>"} expansion (issue #2).
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("addon_cls", ADDONS)
class TestNetworkPresets:
def test_preset_expands_to_allow_rules(self, addon_cls):
addon = addon_cls()
addon._load_network_rules([{"preset": "python"}])
assert {"action": "allow", "host": "pypi.org"} in addon.network_rules
assert all(r["action"] == "allow" for r in addon.network_rules)
assert addon._is_request_allowed("GET", "files.pythonhosted.org") is True
# default deny still applies outside the preset
assert addon._is_request_allowed("GET", "example.com") is False

def test_preset_expands_in_place_preserving_rule_order(self, addon_cls):
# A deny BEFORE the preset must shadow a host the preset would allow.
addon = addon_cls()
addon._load_network_rules([
{"action": "deny", "host": "pypi.org"},
{"preset": "python"},
{"action": "allow", "host": "example.com", "method": "GET"},
])
assert addon._is_request_allowed("GET", "pypi.org") is False
assert addon._is_request_allowed("GET", "files.pythonhosted.org") is True
assert addon._is_request_allowed("GET", "example.com") is True

def test_unknown_preset_fails_loud(self, addon_cls):
addon = addon_cls()
with pytest.raises(RuntimeError, match="unknown network preset 'no-such'"):
addon._load_network_rules([{"preset": "no-such"}])

def test_preset_combined_with_other_keys_is_rejected(self, addon_cls):
addon = addon_cls()
with pytest.raises(RuntimeError, match="must not combine 'preset'"):
addon._load_network_rules([{"preset": "python", "method": "GET"}])

def test_rules_without_preset_pass_through_unchanged(self, addon_cls):
addon = addon_cls()
rules = [
{"action": "allow", "host": "*", "method": "GET"},
{"action": "deny", "host": "*"},
]
addon._load_network_rules(list(rules))
assert addon.network_rules == rules

def test_every_defined_preset_expands(self, addon_cls):
# Guards the definitions themselves: non-empty host lists, and every
# name expands without error so a typo in NETWORK_PRESETS can't ship.
addon = addon_cls()
for name, hosts in common.NETWORK_PRESETS.items():
assert hosts, f"preset {name!r} has an empty host list"
addon._load_network_rules([{"preset": name}])
assert len(addon.network_rules) == len(hosts)

def test_stack_names_have_matching_presets(self, addon_cls):
# `sandcat init --stacks` names double as preset names (PR2 seeds
# them into project settings) — keep the two namespaces in sync.
stacks_bash = (
Path(__file__).resolve().parents[2] / "lib" / "stacks.bash"
).read_text()
m = re.search(r"STACK_NAMES=\(([^)]*)\)", stacks_bash)
assert m, "STACK_NAMES not found in cli/lib/stacks.bash"
for stack in m.group(1).split():
assert stack in common.NETWORK_PRESETS, (
f"stack {stack!r} has no matching network preset"
)


# ---------------------------------------------------------------------------
# Secret substitution — shared logic, parameterised over both variants.
# ---------------------------------------------------------------------------
Expand Down
Loading