diff --git a/README.md b/README.md index 947c8267..09f9010f 100644 --- a/README.md +++ b/README.md @@ -840,6 +840,13 @@ For stricter configurations, replace the wildcard rule with the you need) — e.g. `{"preset": "python"}, {"preset": "github"}` instead of `{"action": "allow", "host": "*", "method": "GET"}`. +`sandcat init --features strict-network` does this for you: the generated +project settings contain one preset entry per selected stack and no wildcard, +so anything beyond the stack registries and the user-settings layer (your +agent's API hosts) is denied by default — including its DNS resolution. +Expect to add a few hosts on first use (`.sandcat/settings.local.json` is the +per-machine place); the startup log and `sandcat proxy` show what got blocked. + ### DNS filtering DNS queries are checked against the same network rules as HTTP requests. If a diff --git a/cli/README.md b/cli/README.md index 398671f8..8d2b8940 100644 --- a/cli/README.md +++ b/cli/README.md @@ -23,7 +23,7 @@ Options: - `--proxy` - Proxy UI mode: `web` (default, mitmweb browser UI) or `tui` (mitmproxy console, use with `sandcat proxy` to attach) - `--secret-provider` / `--sp` - Secret backend: `none` (default), `1password`, `protonpass` (skips prompt when set) - `--1password` - Deprecated alias for `--secret-provider 1password` -- `--features` - Comma-separated optional non-provider features: `tui` (proxy console mode; prefer `--proxy tui`), `no-gitignore` (skip appending the `# Sandcat` block to the project's `.gitignore`; equivalent to `SANDCAT_GITIGNORE=false`), `no-rtk` (skip RTK installation; equivalent to `SANDCAT_RTK=false`) +- `--features` - Comma-separated optional non-provider features: `tui` (proxy console mode; prefer `--proxy tui`), `no-gitignore` (skip appending the `# Sandcat` block to the project's `.gitignore`; equivalent to `SANDCAT_GITIGNORE=false`), `no-rtk` (skip RTK installation; equivalent to `SANDCAT_RTK=false`), `strict-network` (project settings get network presets for the selected stacks instead of the allow-all-GET wildcard; equivalent to `SANDCAT_STRICT_NETWORK=true`) - `--name` - Project name for Docker Compose (default: derived from directory name) - `--path` - Project directory (default: current directory) diff --git a/cli/libexec/init/init b/cli/libexec/init/init index caaeca4e..ca9a97f3 100755 --- a/cli/libexec/init/init +++ b/cli/libexec/init/init @@ -301,14 +301,19 @@ init() { # no-shared-cache → disable shared JVM dependency cache volumes # no-gitignore → skip appending the Sandcat block to .gitignore # no-rtk → skip installing the rtk shell hook + # strict-network → project settings get stack network presets instead + # of the allow-all-GET wildcard (default deny beyond + # the presets and the user-settings layer) local gitignore_enabled=${SANDCAT_GITIGNORE:-true} local rtk_enabled=${SANDCAT_RTK:-true} + local strict_network=${SANDCAT_STRICT_NETWORK:-false} if [[ "$features_provided" != "true" ]]; then local available_features=( "tui (mitmproxy console instead of web UI)" "no-shared-cache (per-project dep cache instead of shared)" "no-gitignore (do not append Sandcat block to .gitignore)" "no-rtk (do not install rtk shell hook)" + "strict-network (stack presets instead of allow-all-GET wildcard)" ) local selected_features selected_features=$(select_multiple "Select optional features (comma-separated numbers, empty for none):" "${available_features[@]}") @@ -321,6 +326,7 @@ init() { no-shared-cache) export SANDCAT_MOUNT_SHARED_CACHE="false" ;; no-gitignore) gitignore_enabled=false ;; no-rtk) rtk_enabled=false ;; + strict-network) strict_network=true ;; esac done fi @@ -333,11 +339,12 @@ init() { no-shared-cache) export SANDCAT_MOUNT_SHARED_CACHE="false" ;; no-gitignore) gitignore_enabled=false ;; no-rtk) rtk_enabled=false ;; + strict-network) strict_network=true ;; 1password) echo "Use --secret-provider 1password instead of --features 1password" | error return 1 ;; - *) echo "Unknown feature: $f (expected: tui, no-shared-cache, no-gitignore, no-rtk)" | error; return 1 ;; + *) echo "Unknown feature: $f (expected: tui, no-shared-cache, no-gitignore, no-rtk, strict-network)" | error; return 1 ;; esac done fi @@ -387,7 +394,11 @@ init() { add_secret_provider_tokens_to_user_settings "$secret_provider" - settings "$project_path/$settings_file" "${services[@]}" + local settings_args=() + if [[ "$strict_network" == "true" ]]; then + settings_args+=(--strict-network --stacks "$stacks_resolved") + fi + settings "${settings_args[@]+"${settings_args[@]}"}" "$project_path/$settings_file" "${services[@]}" local devcontainer_args=( --settings-file "$settings_file" --project-path "$project_path" @@ -447,6 +458,12 @@ init() { else echo " RTK: disabled" | info fi + if [[ "$strict_network" == "true" ]]; then + local preset_summary="${stacks_resolved:-none}" + echo " Network: strict — stack presets: ${preset_summary// /, } (edit .sandcat/settings.json to allow more)" | info + else + echo " Network: default (allow all GET; tighten with --features strict-network)" | info + fi case "$secret_provider" in 1password) echo " Secret provider: 1Password" | info diff --git a/cli/libexec/init/settings b/cli/libexec/init/settings index b00a80bb..3bfdd79e 100755 --- a/cli/libexec/init/settings +++ b/cli/libexec/init/settings @@ -10,11 +10,34 @@ source "$SCT_LIBDIR/constants.bash" # Creates a network settings file for the proxy. # Args: +# [--strict-network] - Replace the template's allow-all-GET wildcard with +# network presets for the given stacks (default deny +# beyond the presets and the user-settings layer) +# [--stacks ""] - Space-separated resolved stack names whose presets +# seed the strict policy (may be empty) # $1 - Path to the settings file # $@ - Service names to include (e.g., claude, copilot, vscode, jetbrains, github) # Outputs: # Creates a JSON settings file from template settings() { + local strict_network=false + local stacks="" + while [[ $# -gt 0 && "$1" == --* ]]; do + case "$1" in + --strict-network) + strict_network=true + shift + ;; + --stacks) + stacks="${2-}" + shift 2 + ;; + *) + echo "settings: unknown option: $1" | error + return 1 + ;; + esac + done local settings_file=$1 shift @@ -25,6 +48,20 @@ settings() { "$SCT_TEMPLATEDIR/settings.json" \ "$settings_file" + if [[ "$strict_network" == "true" ]]; then + # Replace the template's `allow * GET` wildcard with one preset entry + # per stack. The names are expanded to concrete allow rules at proxy + # start by the mitmproxy addon (NETWORK_PRESETS), so the domain lists + # update with the sandcat version instead of freezing in the project. + # With no stacks this leaves an empty list: everything beyond the + # user-settings layer (agent API hosts) is then denied by default. + yq -i -o=json '.network = []' "$settings_file" + local s + for s in $stacks; do + s="$s" yq -i -o=json '.network += [{"preset": strenv(s)}]' "$settings_file" + done + fi + echo "Settings file created at $settings_file" | info # Empty per-machine overrides scaffold — highest-precedence layer in the diff --git a/cli/test/init/init.bats b/cli/test/init/init.bats index 82fc6a8d..315a8e39 100644 --- a/cli/test/init/init.bats +++ b/cli/test/init/init.bats @@ -262,7 +262,7 @@ EOF "'Select IDE:' vscode jetbrains none : echo vscode" \ "'Select secret provider:' 1password none protonpass : echo 1password" stub select_multiple \ - "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' : echo ''" \ + "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' : echo ''" \ "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet zig : echo ''" local expected_name @@ -343,7 +343,7 @@ EOF "'Select IDE:' vscode jetbrains none : echo vscode" \ "'Select secret provider:' none 1password protonpass : echo none" stub select_multiple \ - "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' : echo ''" \ + "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' : echo ''" \ "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet zig : echo ''" local expected_name @@ -422,7 +422,7 @@ EOF "'Select IDE:' vscode jetbrains none : echo vscode" \ "'Select secret provider:' none 1password protonpass : echo none" stub select_multiple \ - "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' : echo 'tui (mitmproxy console instead of web UI)'" \ + "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' : echo 'tui (mitmproxy console instead of web UI)'" \ "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet zig : echo ''" local expected_name @@ -661,4 +661,26 @@ EOF run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "bogus" --secret-provider none assert_failure assert_output --partial "no-rtk" + assert_output --partial "strict-network" +} + +@test "init --features strict-network passes strict flags with resolved stacks to settings" { + stub settings \ + "--strict-network --stacks python $PROJECT_DIR/.sandcat/settings.json claude vscode : :" + stub devcontainer \ + "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" + + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "python" --features "strict-network" --secret-provider none + assert_success + assert_output --partial "Network: strict — stack presets: python" +} + +@test "init without strict-network reports the default network policy" { + stub settings "$PROJECT_DIR/.sandcat/settings.json claude vscode : :" + stub devcontainer \ + "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" + + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "" --secret-provider none + assert_success + assert_output --partial "Network: default (allow all GET" } diff --git a/cli/test/init/settings.bats b/cli/test/init/settings.bats index 22770326..7b6a7f76 100644 --- a/cli/test/init/settings.bats +++ b/cli/test/init/settings.bats @@ -31,6 +31,45 @@ teardown() { [[ -f "$settings_file" ]] } +@test "settings default keeps the template wildcard rule" { + local settings_file="$BATS_TEST_TMPDIR/settings.json" + + run settings "$settings_file" "github" + assert_success + + yq -e '.network[0].action == "allow" and .network[0].host == "*" and .network[0].method == "GET"' \ + "$settings_file" +} + +@test "settings --strict-network seeds stack presets instead of the wildcard" { + local settings_file="$BATS_TEST_TMPDIR/settings.json" + + run settings --strict-network --stacks "python java" "$settings_file" "github" + assert_success + + yq -e '.network | length == 2' "$settings_file" + yq -e '.network[0].preset == "python"' "$settings_file" + yq -e '.network[1].preset == "java"' "$settings_file" + # The wildcard must be gone — that is the whole point of strict mode. + run yq -e '.network[] | select(.host == "*")' "$settings_file" + [ "$status" -ne 0 ] +} + +@test "settings --strict-network with no stacks leaves an empty network list" { + local settings_file="$BATS_TEST_TMPDIR/settings.json" + + run settings --strict-network --stacks "" "$settings_file" "github" + assert_success + + yq -e '.network | length == 0' "$settings_file" +} + +@test "settings rejects unknown option" { + run settings --no-such-flag "$BATS_TEST_TMPDIR/settings.json" + assert_failure + assert_output --partial "unknown option" +} + @test "settings creates empty settings.local.json scaffold when absent" { local settings_file="$BATS_TEST_TMPDIR/settings.json" local local_settings="$BATS_TEST_TMPDIR/settings.local.json"