diff --git a/README.md b/README.md index 6b8d4109..947c8267 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py index 24aecf3e..b5a81ab9 100644 --- a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py +++ b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py @@ -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": ""}` expands, in place, to one `{"action": "allow", +# "host": }` 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__) @@ -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": ""}`` 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): diff --git a/cli/test/mitmproxy/test_mitmproxy_addon.py b/cli/test/mitmproxy/test_mitmproxy_addon.py index b6f30af2..fd152596 100644 --- a/cli/test/mitmproxy/test_mitmproxy_addon.py +++ b/cli/test/mitmproxy/test_mitmproxy_addon.py @@ -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": ""} 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. # ---------------------------------------------------------------------------