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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 19 additions & 2 deletions cli/libexec/init/init
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}")
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions cli/libexec/init/settings
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<a b c>"] - 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

Expand All @@ -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
Expand Down
28 changes: 25 additions & 3 deletions cli/test/init/init.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
39 changes: 39 additions & 0 deletions cli/test/init/settings.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading