diff --git a/README.md b/README.md index dfe24aa..fc18d6c 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,9 @@ Selecting `scala` automatically includes `java` as a dependency. Stacks also install the corresponding VS Code extension (e.g. `rust-analyzer` for Rust, `metals` for Scala). -Optional volume mounts (Claude config, .git, .idea) are configurable in the -generated compose file. Claude config mounts are active by default for the -Claude agent; .git and .idea are commented out. Set `SANDCAT_*` environment +Optional volume mounts (agent config, .git, .idea) are configurable in the +generated compose file. Agent config mounts are active by default for the +selected agent; .git and .idea are commented out. Set `SANDCAT_*` environment variables for scripted usage. See the [CLI README](cli/README.md) for the full list of flags and environment variables. @@ -151,8 +151,9 @@ multiple sandboxes are running in parallel. **`compose-all.yml`** — `network_mode: "service:wg-client"` routes all traffic through the WireGuard tunnel. The `mitmproxy-config` volume gives your container -access to the CA cert, env vars, and secret placeholders. The `~/.claude/*` -bind-mounts forward host Claude Code customizations — remove any mount whose +access to the CA cert, env vars, and secret placeholders. The agent-specific +config bind-mounts (for example `~/.claude/*` or `~/.cursor/*`) forward host +customizations — remove any mount whose source does not exist on your host. **`Dockerfile.app`** — uses [mise](https://mise.jdx.dev/) to manage language @@ -423,8 +424,10 @@ restart-proxy` after changing 1Password items. ### How it works internally 1. The mitmproxy container mounts `~/.config/sandcat/settings.json` (read-only) - and the project's `.sandcat/` directory (read-only) alongside the - `mitmproxy_addon.py` addon script. + and the project's `.sandcat/` directory (read-only) alongside the addon + script. The addon comes in two agent-specific variants + (`mitmproxy_addon_claude.py`, `mitmproxy_addon_cursor.py`) that share their + common logic via the `mitmproxy_addon_common.py` library. 2. On startup, the addon reads all available settings files (user, project, local), merges them according to the precedence rules above, and writes `sandcat.env` to the `mitmproxy-config` shared volume @@ -479,6 +482,57 @@ commands are available inside the container. Remove any mount whose source does not exist on your host — Docker will otherwise create an empty directory in its place. +### Cursor CLI + +Cursor CLI support is available via `sandcat init --agent cursor`. + +- The current template uses temporary compatibility defaults for auth/network: + - **Auth passthrough via placeholder substitution.** The container sees only + `SANDCAT_PLACEHOLDER_CURSOR_API_KEY`; the real `CURSOR_API_KEY` is injected + by the mitmproxy addon only for allowed Cursor hosts. + - **HTTP/1 compatibility bootstrap.** On startup, Sandcat forces + `.network.useHttp1ForAgent = true` in Cursor CLI config to avoid known + proxy/TLS instability with HTTP/2 streaming through mitmproxy. + - **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`). + + Those streaming-safe flags are **Cursor-only** — they are intentionally + omitted on the Claude path (`sct_agent_mitm_streaming_flags`). With + `stream_large_bodies` unset, mitmproxy buffers request bodies up to ~1 MB + before forwarding, which lets the addon's `_substitute_secrets` run a + body-content scan for placeholder leaks. Setting them on Claude would + weaken that defence-in-depth check; on Cursor they are required to keep + Connect/HTTP-2 streaming responses stable, and the body-leak check is + instead enforced via header/URL scans plus the textual-only body-mutation + gate (binary protobuf bodies are left untouched). + - **Streaming detection is path-only.** The Cursor addon decides whether a + request is streaming purely from the request path + (`/agent.v1.AgentService/Run*`, `/aiserver.v1.RepositoryService/...`). + A client-supplied `content-type: application/connect+proto` header alone + is **not** sufficient — accepting it would let any request with the right + header bypass body substitution and the placeholder leak check. + These defaults are conservative and may be relaxed when Cursor proxy behavior + is consistently stable across environments. +- Use `CURSOR_API_KEY` in your user settings for Cursor authentication. +- On container startup, Sandcat writes + `.network.useHttp1ForAgent = true` to + `~/.config/cursor/cli-config.json` (`~/.cursor/cli-config.json` is also + updated for compatibility). +- `SANDCAT_MOUNT_CURSOR_CONFIG=true` mounts `~/.cursor/AGENTS.md`, + `~/.cursor/rules`, and `~/.cursor/skills` into the agent container. +- **Cursor CLI TLS through mitmproxy.** The Cursor CLI bundles its own Node.js + binary with compiled-in Mozilla CA roots. Sandcat sets + `NODE_OPTIONS=--use-openssl-ca` so the bundled Node.js uses the system CA + store (which includes the mitmproxy CA) instead of its built-in roots. + When Cursor honors that environment setting, mitmproxy can intercept Cursor + API traffic and perform `SANDCAT_PLACEHOLDER_CURSOR_API_KEY` substitution + transparently. +- Provider-specific onboarding/bootstrap logic is intentionally minimal in this + first iteration and can be extended in project-level Dockerfile/scripts. + ## Architecture ### Containers and network @@ -819,8 +873,11 @@ mitmproxy CA. `app-init.sh` installs it into the system trust store, which is enough for most tools — but some runtimes bring their own CA handling: - **Node.js** bundles its own CA certificates and ignores the system store. - `app-init.sh` sets `NODE_EXTRA_CA_CERTS` automatically. If you write a custom - entrypoint, make sure to include this or Node-based tools will fail TLS + `app-init.sh` sets `NODE_EXTRA_CA_CERTS` and + `NODE_OPTIONS=--use-openssl-ca` automatically. The `--use-openssl-ca` flag + is required for tools that bundle their own Node.js binary (e.g. Cursor CLI) + where `NODE_EXTRA_CA_CERTS` alone may not be honored. If you write a custom + entrypoint, make sure to include both or Node-based tools will fail TLS verification. - **Rust** programs using `rustls` with the `webpki-roots` crate bundle CA certificates at compile time and will not trust the mitmproxy CA. Use diff --git a/cli/README.md b/cli/README.md index 5b737dd..e7bb32f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -9,13 +9,13 @@ Requires `docker` (and `docker compose`) and [`yq`](https://github.com/mikefarah ### `sandcat init` Initializes sandcat for a project. Prompts for any options not provided via flags, then sets up the necessary -configuration files and network settings. Optional volume mounts (Claude config, .git, .idea) are included as -commented-out entries in the generated compose file (Claude config defaults to active for the Claude agent). +configuration files and network settings. Optional volume mounts (agent config, .git, .idea) are included as +commented-out entries in the generated compose file (agent config defaults to active for the selected agent). Options: -- `--agent` - Agent type: `claude` (skips prompt) +- `--agent` - Agent type: `claude`, `cursor` (skips prompt) - `--ide` - IDE for devcontainer mode: `vscode`, `jetbrains`, `none` (skips prompt) -- `--stacks` - Comma-separated development stacks to install: `node`, `python`, `java`, `rust`, `go`, `scala`, `ruby`, `dotnet` (skips prompt) +- `--stacks` - Comma-separated development stacks to install: `node`, `python`, `java`, `rust`, `go`, `scala`, `ruby`, `dotnet`, `zig` (skips prompt) - `--proxy` - Proxy UI mode: `web` (default, mitmweb browser UI) or `tui` (mitmproxy console, use with `sandcat proxy` to attach) - `--features` - Comma-separated optional features: `tui` (proxy console mode), `1password` (1Password secret resolution via `op` CLI) - `--1password` - Shorthand for `--features 1password` @@ -30,10 +30,18 @@ Fully non-interactive examples: ```bash sandcat init --agent claude --ide vscode --stacks "python,node" --name myproject --path /some/dir +# Cursor CLI provider +sandcat init --agent cursor --ide vscode --stacks "python,node" --name myproject --path /some/dir + # With 1Password integration sandcat init --agent claude --ide vscode --features "1password" --name myproject ``` +Note: Cursor agent support currently uses compatibility defaults for auth/network +settings while provider-specific hardening is being expanded. +Use `CURSOR_API_KEY` for Cursor authentication. +Sandcat always bootstraps Cursor CLI with `.network.useHttp1ForAgent = true`. + #### `sandcat init devcontainer` Sets up a devcontainer configuration for an agent. Copies devcontainer template files and customizes the @@ -42,7 +50,7 @@ compose-all.yml. Options: - `--settings-file` - Path to the settings file (relative to project directory) - `--project-path` - Path to the project directory -- `--agent` - The agent name (e.g., `claude`) +- `--agent` - The agent name (e.g., `claude`, `cursor`) - `--ide` - The IDE name (e.g., `vscode`, `jetbrains`, `none`) (optional) - `--stacks` - Space-separated development stacks (e.g., `"python java"`) (optional) - `--name` - Project name for Docker Compose (default: `{dir}-sandbox`) @@ -143,8 +151,9 @@ cli/ ### Configuration (set before running `sandcat init`) These override defaults during compose file generation. Optional volumes default to `false` (commented out), -except `SANDCAT_MOUNT_CLAUDE_CONFIG` which defaults to `true` for the Claude agent. +except provider config mounts, which default to `true` for the selected agent. - `SANDCAT_MOUNT_CLAUDE_CONFIG` - `true` to mount host `~/.claude` config (Claude agent only) +- `SANDCAT_MOUNT_CURSOR_CONFIG` - `true` to mount host `~/.cursor` config (Cursor agent only) - `SANDCAT_MOUNT_GIT_READONLY` - `true` to mount `.git/` directory as read-only - `SANDCAT_MOUNT_IDEA_READONLY` - `true` to mount `.idea/` directory as read-only (JetBrains) diff --git a/cli/lib/agents.bash b/cli/lib/agents.bash new file mode 100644 index 0000000..98ae99c --- /dev/null +++ b/cli/lib/agents.bash @@ -0,0 +1,347 @@ +#!/usr/bin/env bash + +# Returns supported agents as a space-separated list. +sct_available_agents() { + echo "claude cursor" +} + +# Returns 0 if agent is valid. +# Args: +# $1 - Agent name +sct_is_valid_agent() { + local agent=$1 + local item + for item in $(sct_available_agents); do + if [[ "$item" == "$agent" ]]; then + return 0 + fi + done + return 1 +} + +# Returns the optional config mount env var for an agent. +# Args: +# $1 - Agent name +sct_agent_mount_env_var() { + local agent=$1 + case "$agent" in + claude) echo "SANDCAT_MOUNT_CLAUDE_CONFIG" ;; + cursor) echo "SANDCAT_MOUNT_CURSOR_CONFIG" ;; + *) echo "" ;; + esac +} + +# Pre-creates the host paths that the optional agent config mounts will bind +# read-only into the container. Without this, Docker materialises any missing +# bind source as a root-owned empty directory in the user's $HOME — annoying +# to clean up and confusing because the directory shows up out of nowhere. +# +# Each path on its own line. Lines ending with '/' are treated as directories, +# everything else as files (touch -a). Pre-creating only happens when the +# agent's mount env var is "true" (or unset, which defaults to true via +# customize_compose_file). +# +# Args: +# $1 - Agent name +sct_agent_host_config_paths() { + local agent=$1 + case "$agent" in + claude) + cat <<'EOF' +$HOME/.claude/agents/ +$HOME/.claude/commands/ +$HOME/.claude/CLAUDE.md +EOF + ;; + cursor) + cat <<'EOF' +$HOME/.cursor/rules/ +$HOME/.cursor/skills/ +$HOME/.cursor/AGENTS.md +EOF + ;; + *) + echo "" + ;; + esac +} + +# Pre-creates host config paths for the selected agent so Docker doesn't have +# to invent them as root-owned. No-op when the user opts out of the optional +# config mount via SANDCAT_MOUNT__CONFIG=false. +# +# Args: +# $1 - Agent name +ensure_host_agent_config_paths() { + local agent=$1 + local mount_var + mount_var=$(sct_agent_mount_env_var "$agent") + if [[ -z "$mount_var" ]]; then + return 0 + fi + + # Match the default in customize_compose_file: missing/unset means true. + local mount_value="${!mount_var:-true}" + if [[ "$mount_value" != "true" ]]; then + return 0 + fi + + local line expanded + while IFS= read -r line; do + [[ -z "$line" ]] && continue + # Expand $HOME and other simple env vars without invoking eval on + # untrusted input (the values come from this file, not user input). + expanded="${line//\$HOME/$HOME}" + if [[ "$expanded" == */ ]]; then + mkdir -p "${expanded%/}" + else + mkdir -p "$(dirname "$expanded")" + # touch -a updates atime only; creates the file if missing + # without bumping mtime when it already exists. + touch -a "$expanded" + fi + done < <(sct_agent_host_config_paths "$agent") +} + +# Returns one-line API key help text for init output. +# Args: +# $1 - Agent name +sct_agent_api_key_help() { + local agent=$1 + case "$agent" in + claude) echo "ANTHROPIC_API_KEY your Anthropic API key (for Claude Code)" ;; + cursor) echo "CURSOR_API_KEY your Cursor API key (for Cursor CLI)" ;; + *) echo "ANTHROPIC_API_KEY API key for your selected agent" ;; + esac +} + +# Returns the 1Password reference example for the agent's primary API key, +# rendered as the user-settings line shown in init's "next steps" section. +# +# Args: +# $1 - Agent name +sct_agent_op_api_key_help() { + local agent=$1 + case "$agent" in + cursor) + echo "CURSOR_API_KEY \"op\": \"op://vault/Cursor API Key/credential\"" + ;; + claude|*) + echo "ANTHROPIC_API_KEY \"op\": \"op://vault/Anthropic API Key/credential\"" + ;; + esac +} + +# Hook fired right after `init` creates the user-settings file, used by +# agents that need to seed agent-specific defaults into the JSON without +# overwriting user-provided values. +# +# Implementations need access to sct_home (constants.bash) and live in +# `init` (e.g. ensure_cursor_user_settings_defaults). The hook just +# dispatches to the right helper or no-ops for agents that don't need one. +# +# Args: +# $1 - Agent name +sct_agent_post_user_settings_hook() { + local agent=$1 + case "$agent" in + cursor) + if declare -F ensure_cursor_user_settings_defaults >/dev/null; then + ensure_cursor_user_settings_defaults + fi + ;; + *) + return 0 + ;; + esac +} + +# Returns extension id for a selected agent. +# Args: +# $1 - Agent name +sct_agent_vscode_extension() { + local agent=$1 + case "$agent" in + claude) echo "anthropic.claude-code" ;; + cursor) echo "anysphere.cursor" ;; + *) echo "" ;; + esac +} + +# Returns devcontainer settings block for selected agent. +# Args: +# $1 - Agent name +sct_agent_devcontainer_settings_block() { + local agent=$1 + case "$agent" in + claude) + cat <<'EOF' + // Sandcat provides the security boundary (network isolation, + // secret substitution, iptables kill-switch), so permission + // prompts inside the container add friction without meaningful + // security benefit. Remove these if you prefer interactive + // permission approval. + "claudeCode.allowDangerouslySkipPermissions": true, + "claudeCode.initialPermissionMode": "bypassPermissions", + // Optional: override the default Claude model. + "claudeCode.selectedModel": "opus" +EOF + ;; + cursor) + cat <<'EOF' + // Cursor CLI support currently uses compatibility defaults for + // auth/network config. Add Cursor-specific settings here if needed. +EOF + ;; + *) + echo "" + ;; + esac +} + +# Returns the agent's services.agent environment entries as KEY=VALUE lines, +# one per line. The caller is responsible for emitting the YAML `environment:` +# key only when the result is non-empty — Docker Compose rejects an empty +# `environment: {}` block. +# +# Args: +# $1 - Agent name +sct_agent_compose_environment_entries() { + local agent=$1 + case "$agent" in + claude) + echo "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1" + ;; + cursor|*) + echo "" + ;; + esac +} + +# Returns Dockerfile install block for selected agent. +# Args: +# $1 - Agent name +sct_agent_docker_install_block() { + local agent=$1 + case "$agent" in + claude) + cat <<'EOF' +# Install Claude Code (native binary — no Node.js required). +RUN curl -fsSL https://claude.ai/install.sh | bash +EOF + ;; + cursor) + cat <<'EOF' +# Install Cursor CLI. +RUN curl https://cursor.com/install -fsS | bash +EOF + ;; + *) + echo "" + ;; + esac +} + +# Returns Dockerfile config-home preparation block for selected agent. +# Args: +# $1 - Agent name +sct_agent_docker_home_prep_block() { + local agent=$1 + case "$agent" in + claude) + cat <<'EOF' +# Pre-create ~/.claude so Docker bind-mounts (CLAUDE.md, agents/, commands/) +# don't cause it to be created as root-owned. +RUN mkdir -p /home/vscode/.claude +RUN echo 'alias claude-yolo="claude --dangerously-skip-permissions"' >> /home/vscode/.bashrc +EOF + ;; + cursor) + cat <<'EOF' +# Pre-create Cursor config directories so optional host config mounts do not +# create them as root-owned. +RUN mkdir -p /home/vscode/.cursor /home/vscode/.config/cursor +EOF + ;; + *) + echo "" + ;; + esac +} + +# 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. +# +# Claude's traffic is plain JSON request/response, so leaving the body +# buffered means _substitute_secrets in the addon can run a content-based +# placeholder leak check (see mitmproxy_addon_common.py:_substitute_secrets). +# Returning the flags for Claude would weaken that check, so the dispatcher +# returns an empty string for Claude and the unknown-agent fallback. +# +# Args: +# $1 - Agent name +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" + ;; + claude|*) + echo "" + ;; + esac +} + +# Returns app-user-init bootstrap block for selected agent. +# Args: +# $1 - Agent name +sct_agent_user_init_block() { + local agent=$1 + case "$agent" in + claude) + cat <<'EOF' +# Seed the onboarding flag so Claude Code uses the API key without interactive +# setup. Only written when the user configured an ANTHROPIC_API_KEY secret. +if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + echo '{"hasCompletedOnboarding":true}' > "$HOME/.claude.json" +fi + +# Claude Code is installed at build time (Dockerfile.app). +# Background update so it doesn't block startup. +(claude install >/dev/null 2>&1 &) +EOF + ;; + cursor) + cat <<'EOF' +# Cursor auth uses the placeholder value from sandcat.env. The mitmproxy addon +# substitutes it with the real secret on allowed outbound Cursor requests. + +# Cursor CLI networking bootstrap. +# Some proxy/TLS environments are unstable with HTTP/2 streaming, so always +# enforce the Cursor CLI HTTP/1 compatibility setting. +if command -v jq >/dev/null 2>&1; then + for CURSOR_CLI_CONFIG in "$HOME/.config/cursor/cli-config.json" "$HOME/.cursor/cli-config.json"; do + mkdir -p "$(dirname "$CURSOR_CLI_CONFIG")" + if [ ! -f "$CURSOR_CLI_CONFIG" ]; then + echo '{"version":1}' > "$CURSOR_CLI_CONFIG" + fi + tmp="$(mktemp)" + jq \ + '.network = (.network // {}) | .network.useHttp1ForAgent = true' \ + "$CURSOR_CLI_CONFIG" > "$tmp" \ + && mv "$tmp" "$CURSOR_CLI_CONFIG" \ + || { rm -f "$tmp"; echo "Warning: failed to update $CURSOR_CLI_CONFIG via jq" >&2; } + done +else + echo "Warning: jq not found; cannot apply Cursor CLI HTTP/1 bootstrap config" >&2 +fi +EOF + ;; + *) + echo "" + ;; + esac +} diff --git a/cli/lib/composefile.bash b/cli/lib/composefile.bash index dac3ec5..80ce948 100644 --- a/cli/lib/composefile.bash +++ b/cli/lib/composefile.bash @@ -6,11 +6,14 @@ source "$SCT_LIBDIR/require.bash" source "$SCT_LIBDIR/path.bash" # shellcheck source=constants.bash source "$SCT_LIBDIR/constants.bash" +# shellcheck source=agents.bash +source "$SCT_LIBDIR/agents.bash" # Customizes a Docker Compose file with settings and optional user configurations. # Optional volumes are added as commented-out entries by default. Set environment # variables to "true" before calling this function to add them as active mounts: # - SANDCAT_MOUNT_CLAUDE_CONFIG: "true" to mount host Claude config (~/.claude) +# - SANDCAT_MOUNT_CURSOR_CONFIG: "true" to mount host Cursor config (~/.cursor) # - SANDCAT_MOUNT_GIT_READONLY: "true" to mount .git directory as read-only # - SANDCAT_MOUNT_IDEA_READONLY: "true" to mount .idea directory as read-only # Args: @@ -43,10 +46,14 @@ customize_compose_file() { add_settings_volume "$compose_file" "$settings_file" - if [[ $agent == "claude" ]] - then - add_claude_config_volumes "$compose_file" "${SANDCAT_MOUNT_CLAUDE_CONFIG:=true}" - fi + case "$agent" in + claude) + add_claude_config_volumes "$compose_file" "${SANDCAT_MOUNT_CLAUDE_CONFIG:=true}" + ;; + cursor) + add_cursor_config_volumes "$compose_file" "${SANDCAT_MOUNT_CURSOR_CONFIG:=true}" + ;; + esac add_git_readonly_volume "$compose_file" "${SANDCAT_MOUNT_GIT_READONLY:=false}" add_idea_readonly_volume "$compose_file" "${SANDCAT_MOUNT_IDEA_READONLY:-false}" @@ -79,7 +86,8 @@ enable_1password() { } # Switches the mitmproxy service from web UI to console (mitmdump) mode. -# Replaces the mitmweb command with mitmdump and removes the web UI port. +# Replaces the mitmweb command with mitmdump, strips mitmweb-only flags +# (--web-host and --set web_password), and removes the web UI port. # mitmdump logs flows as text to stdout, viewable via docker compose logs. # Args: # $1 - Path to the compose-proxy.yml file @@ -88,7 +96,11 @@ set_proxy_tui_mode() { local compose_file=$1 yq -i ' - .services.mitmproxy.command = "mitmdump --mode wireguard -s /scripts/mitmproxy_addon.py" | + .services.mitmproxy.command |= ( + sub("^mitmweb\\b", "mitmdump") | + sub("\\s+--web-host\\s+\\S+", "") | + sub("\\s+--set\\s+web_password=\\S+", "") + ) | del(.services.mitmproxy.ports) ' "$compose_file" } @@ -211,6 +223,22 @@ add_claude_config_volumes() { add_volume_entry "$compose_file" '${HOME}/.claude/commands:/home/vscode/.claude/commands:ro' "$active" } +# Adds Cursor config volume mounts to the agent service. +# Args: +# $1 - Path to the Docker Compose file +# $2 - true to add as active, false to add as comment +add_cursor_config_volumes() { + local compose_file=$1 + local active=${2:-true} + + # shellcheck disable=SC2016 + add_volume_entry "$compose_file" '${HOME}/.cursor/AGENTS.md:/home/vscode/.cursor/AGENTS.md:ro' "$active" 'Host Cursor config (optional)' + # shellcheck disable=SC2016 + add_volume_entry "$compose_file" '${HOME}/.cursor/rules:/home/vscode/.cursor/rules:ro' "$active" + # shellcheck disable=SC2016 + add_volume_entry "$compose_file" '${HOME}/.cursor/skills:/home/vscode/.cursor/skills:ro' "$active" +} + # Adds .git directory mount as read-only to the agent service. # Args: diff --git a/cli/lib/devcontainer.bash b/cli/lib/devcontainer.bash index f8ba6d5..33a8cc9 100644 --- a/cli/lib/devcontainer.bash +++ b/cli/lib/devcontainer.bash @@ -2,6 +2,8 @@ # shellcheck source=stacks.bash source "$SCT_LIBDIR/stacks.bash" +# shellcheck source=agents.bash +source "$SCT_LIBDIR/agents.bash" # Replaces __PROJECT_NAME__ placeholder with the actual project name in devcontainer.json. # @@ -88,3 +90,154 @@ customize_devcontainer_extensions() { done < "$devcontainer_json" > "$tmpfile" mv "$tmpfile" "$devcontainer_json" } + +# Whole-line placeholder replacement. +# +# For each line in : if the line contains , replace the *entire* +# line with (which may itself span multiple lines). When +# is empty, the placeholder line is dropped entirely. +# +# Tokens are matched in order; the first match per line wins. +# +# Args: +# $1 - File to modify in place +# $2..$N - Alternating pairs +apply_template_placeholders() { + local file=$1 + shift + + local tokens=() replacements=() + while [[ $# -ge 2 ]]; do + tokens+=("$1") + replacements+=("$2") + shift 2 + done + + local tmpfile="${file}.tmp" + local line i matched + while IFS= read -r line || [[ -n "$line" ]]; do + matched=0 + for i in "${!tokens[@]}"; do + if [[ "$line" == *"${tokens[$i]}"* ]]; then + matched=1 + if [[ -n "${replacements[$i]}" ]]; then + printf '%s\n' "${replacements[$i]}" + fi + break + fi + done + if [[ "$matched" == 0 ]]; then + printf '%s\n' "$line" + fi + done < "$file" > "$tmpfile" + mv "$tmpfile" "$file" +} + +# In-line placeholder replacement. +# +# For each line: replace every occurrence of with . +# Use this when the placeholder is embedded inside a longer line (e.g. the +# mitmproxy command line) rather than occupying the whole line. +# +# Args: +# $1 - File to modify in place +# $2..$N - Alternating pairs +apply_inline_placeholders() { + local file=$1 + shift + + local tokens=() replacements=() + while [[ $# -ge 2 ]]; do + tokens+=("$1") + replacements+=("$2") + shift 2 + done + + local tmpfile="${file}.tmp" + local line i + while IFS= read -r line || [[ -n "$line" ]]; do + for i in "${!tokens[@]}"; do + line="${line//${tokens[$i]}/${replacements[$i]}}" + done + printf '%s\n' "$line" + done < "$file" > "$tmpfile" + mv "$tmpfile" "$file" +} + +# Replaces provider-specific placeholders in generated templates. +# Args: +# $1 - Path to devcontainer directory +# $2 - Agent name +customize_agent_templates() { + local devcontainer_dir=$1 + local agent=$2 + + local extension settings_block environment_entries docker_install_block docker_home_prep_block user_init_block + local mitm_addon_file mitm_http2 mitm_streaming_flags + extension=$(sct_agent_vscode_extension "$agent") + settings_block=$(sct_agent_devcontainer_settings_block "$agent") + environment_entries=$(sct_agent_compose_environment_entries "$agent") + docker_install_block=$(sct_agent_docker_install_block "$agent") + docker_home_prep_block=$(sct_agent_docker_home_prep_block "$agent") + user_init_block=$(sct_agent_user_init_block "$agent") + mitm_streaming_flags=$(sct_agent_mitm_streaming_flags "$agent") + case "$agent" in + cursor) + mitm_addon_file="mitmproxy_addon_cursor.py" + mitm_http2="true" + ;; + claude|*) + mitm_addon_file="mitmproxy_addon_claude.py" + mitm_http2="true" + ;; + esac + + # Pre-format the extension entry so apply_template_placeholders can drop + # the placeholder line wholesale when no extension is contributed. + local extension_replacement="" + if [[ -n "$extension" ]]; then + extension_replacement=$(printf '\t\t\t\t"%s",' "$extension") + fi + + apply_template_placeholders \ + "$devcontainer_dir/devcontainer.json" \ + "__AGENT_EXTENSION__" "$extension_replacement" \ + "__AGENT_SETTINGS__" "$settings_block" + + # services.agent.environment is added via yq only when the agent + # contributes entries — compose rejects `environment: {}`. Building the + # array structurally avoids fragile line-counting in compose-all.yml. + if [[ -n "$environment_entries" ]]; then + local entry yq_array="" + while IFS= read -r entry; do + [[ -z "$entry" ]] && continue + # Wrap each entry as a JSON string for yq's expression parser; + # escape backslashes and double quotes so KEY=VALUE pairs with + # special characters round-trip correctly. + local escaped="${entry//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + yq_array+="\"${escaped}\"," + done <<< "$environment_entries" + yq_array="[${yq_array%,}]" + yq -i ".services.agent.environment = ${yq_array}" "$devcontainer_dir/compose-all.yml" + fi + + apply_template_placeholders \ + "$devcontainer_dir/Dockerfile.app" \ + "__AGENT_DOCKER_INSTALL__" "$docker_install_block" \ + "__AGENT_DOCKER_HOME_PREP__" "$docker_home_prep_block" + + apply_template_placeholders \ + "$devcontainer_dir/sandcat/scripts/app-user-init.sh" \ + "__AGENT_USER_INIT__" "$user_init_block" + + # mitmproxy command/addon placeholders are inline (embedded in the + # `command:` line). When streaming flags expand to empty (Claude path), + # the resulting double space between adjacent tokens is harmless for + # shell argv splitting. + apply_inline_placeholders \ + "$devcontainer_dir/sandcat/compose-proxy.yml" \ + "__AGENT_MITM_ADDON__" "$mitm_addon_file" \ + "__MITM_HTTP2__" "$mitm_http2" \ + "__AGENT_MITM_STREAMING_FLAGS__" "$mitm_streaming_flags" +} diff --git a/cli/lib/logging.bash b/cli/lib/logging.bash index eb6b585..aa663f6 100644 --- a/cli/lib/logging.bash +++ b/cli/lib/logging.bash @@ -4,9 +4,14 @@ # Example usage: # `echo 'Terrible error' | error` -if ! tput sgr0 &>/dev/null -then - tput() { :; } +if ! tput sgr0 &>/dev/null; then + # Color output is best-effort; logging must never fail if TERM is unset + # or if terminal capabilities are unavailable. + _sct_tput() { :; } +else + _sct_tput() { + tput "$@" 2>/dev/null || true + } fi _log() { @@ -16,10 +21,10 @@ _log() { ts=$(date +%T) while IFS= read -r line; do echo -n "$ts " - tput setaf "$color" - tput bold + _sct_tput setaf "$color" + _sct_tput bold echo -n "$label " - tput sgr0 + _sct_tput sgr0 echo "$line" done } >&2 diff --git a/cli/lib/stacks.bash b/cli/lib/stacks.bash index 23f9480..a1163d8 100644 --- a/cli/lib/stacks.bash +++ b/cli/lib/stacks.bash @@ -2,7 +2,7 @@ # Development stack definitions for sandcat init. # Uses case functions instead of associative arrays for Bash 3.2 compatibility. -STACK_NAMES=(node python java rust go scala ruby dotnet) +STACK_NAMES=(node python java rust go scala ruby dotnet zig) # Returns the mise install command for a stack. stack_mise_cmd() { @@ -29,7 +29,7 @@ stack_extension() { scala) echo "scalameta.metals" ;; ruby) echo "shopify.ruby-lsp" ;; dotnet) echo "ms-dotnettools.csdevkit" ;; - dotnet) echo "ziglang.vscode-zig" ;; + zig) echo "ziglang.vscode-zig" ;; *) echo "" ;; esac } diff --git a/cli/libexec/init/devcontainer b/cli/libexec/init/devcontainer index 7d7b460..58c3a62 100755 --- a/cli/libexec/init/devcontainer +++ b/cli/libexec/init/devcontainer @@ -101,6 +101,7 @@ devcontainer() { customize_dockerfile "$devcontainer_dir/Dockerfile.app" customize_devcontainer_extensions "$devcontainer_dir/devcontainer.json" fi + customize_agent_templates "$devcontainer_dir" "$agent" if [[ "$proxy_mode" == "tui" ]]; then set_proxy_tui_mode "$devcontainer_dir/sandcat/compose-proxy.yml" diff --git a/cli/libexec/init/init b/cli/libexec/init/init index 27a59bd..5424819 100755 --- a/cli/libexec/init/init +++ b/cli/libexec/init/init @@ -13,10 +13,28 @@ source "$SCT_LIBDIR/constants.bash" source "$SCT_LIBDIR/path.bash" # shellcheck source=../../lib/stacks.bash source "$SCT_LIBDIR/stacks.bash" +# shellcheck source=../../lib/agents.bash +source "$SCT_LIBDIR/agents.bash" + +# Returns the user settings template path for a selected agent. +# Args: +# $1 - Agent name (claude, cursor) +user_settings_template_path() { + local agent=${1:-claude} + local template="$SCT_TEMPLATEDIR/settings-user-${agent}.json" + if [[ ! -f "$template" ]]; then + echo "Missing user settings template for agent '$agent': $template" | error + return 1 + fi + echo "$template" +} # Creates ~/.config/sandcat/settings.json if it doesn't already exist. # Derives git identity from the host's git config. +# Args: +# $1 - Agent name (optional, defaults to claude) create_user_settings() { + local agent=${1:-claude} local user_settings_dir user_settings_dir="$(sct_home)" local user_settings="$user_settings_dir/settings.json" @@ -26,7 +44,7 @@ create_user_settings() { fi mkdir -p "$user_settings_dir" - cp "$SCT_TEMPLATEDIR/settings-user.json" "$user_settings" + cp "$(user_settings_template_path "$agent")" "$user_settings" # Derive git identity from host config local git_name git_email @@ -40,6 +58,28 @@ create_user_settings() { ' "$user_settings" } +# Ensures Cursor-specific defaults exist in an already-created user settings file +# without overriding user-provided values. +ensure_cursor_user_settings_defaults() { + local user_settings + user_settings="$(sct_home)/settings.json" + + if [[ ! -f "$user_settings" ]]; then + return + fi + + yq -i -o json ' + .env = (.env // {}) | + .secrets = (.secrets // {}) | + .secrets.CURSOR_API_KEY = (.secrets.CURSOR_API_KEY // {"value": "", "hosts": []}) | + .secrets.CURSOR_API_KEY.hosts = ( + (.secrets.CURSOR_API_KEY.hosts // []) + + ["api.cursor.sh", "api2.cursor.sh", "*.cursor.sh", "*.cursor.com"] + | unique + ) + ' "$user_settings" +} + # Adds op_service_account_token field to user settings if not already present. add_op_token_to_user_settings() { local user_settings @@ -58,7 +98,7 @@ add_op_token_to_user_settings() { # Initializes sandcat for a project. # Prompts for any options not provided via flags. # Args: -# --agent - Agent type (claude, copilot, codex) +# --agent - Agent type (claude, cursor) # --ide - IDE for devcontainer mode (vscode, jetbrains, none) # --name - Project name for Docker Compose # --path - Project directory path @@ -154,11 +194,12 @@ init() { name=$(read_line "Project name [$default_name]:") fi - local available_agents=(claude) + local available_agents=() + read -ra available_agents <<< "$(sct_available_agents)" if [[ -z "$agent" ]] then agent=$(select_option "Select agent:" "${available_agents[@]}") - elif [[ ! " ${available_agents[*]} " =~ [[:space:]]${agent}[[:space:]] ]] + elif ! sct_is_valid_agent "$agent" then echo "Invalid agent: $agent (expected: ${available_agents[*]})" | error return 1 @@ -257,7 +298,13 @@ init() { services+=("$ide") fi - create_user_settings + create_user_settings "$agent" + # Agent-specific user-settings seeding (e.g. cursor's CURSOR_API_KEY hosts). + sct_agent_post_user_settings_hook "$agent" + + # Pre-create host paths that the optional agent config mounts will bind, + # so Docker doesn't auto-create them as root-owned dirs in the user's home. + ensure_host_agent_config_paths "$agent" if [[ "$onepassword" == "true" ]]; then add_op_token_to_user_settings @@ -294,7 +341,7 @@ init() { echo "Next steps:" | info echo " Edit ~/.config/sandcat/settings.json to add your API keys:" | info if [[ "$onepassword" == "true" ]]; then - echo " ANTHROPIC_API_KEY \"op\": \"op://vault/Anthropic API Key/credential\"" | info + echo " $(sct_agent_op_api_key_help "$agent")" | info echo " GITHUB_TOKEN \"op\": \"op://vault/GitHub Token/credential\"" | info echo " (adjust vault/item names to match your 1Password setup," | info echo " or use plain values with \"value\": \"sk-ant-...\")" | info @@ -305,7 +352,7 @@ init() { echo " 3. Add the token to ~/.config/sandcat/settings.json:" | info echo " \"op_service_account_token\": \"ops_...\"" | info else - echo " ANTHROPIC_API_KEY your Anthropic API key (for Claude Code)" | info + echo " $(sct_agent_api_key_help "$agent")" | info echo " GITHUB_TOKEN a GitHub personal access token (for git push, gh cli)" | info fi echo " Then run: sandcat run, or reopen the project using the dev container" | info diff --git a/cli/templates/devcontainer/Dockerfile.app b/cli/templates/devcontainer/Dockerfile.app index 22ff4da..fd9d696 100644 --- a/cli/templates/devcontainer/Dockerfile.app +++ b/cli/templates/devcontainer/Dockerfile.app @@ -3,14 +3,22 @@ FROM mcr.microsoft.com/devcontainers/base:debian # ca-certificates, curl, git are already in the devcontainers base image. # fd-find: fast file finder (aliased to fd below) # fzf: fuzzy finder for files and command history -# gh: GitHub CLI +# gh: GitHub CLI (official apt repo for newer releases) # gosu: drops privileges in the entrypoint # jq: JSON processor # ripgrep: fast recursive grep (rg) # tmux: terminal multiplexer # vim: text editor RUN apt-get update \ - && apt-get install -y --no-install-recommends fd-find fzf gh gosu jq ripgrep tmux vim \ + && apt-get install -y --no-install-recommends ca-certificates curl fd-find fzf gosu jq ripgrep tmux vim \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=/etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ && ln -s $(which fdfind) /usr/local/bin/fd \ && rm -rf /var/lib/apt/lists/* @@ -20,8 +28,7 @@ COPY --chown=vscode:vscode sandcat/tmux.conf /home/vscode/.tmux.conf USER vscode -# Install Claude Code (native binary — no Node.js required). -RUN curl -fsSL https://claude.ai/install.sh | bash +# __AGENT_DOCKER_INSTALL__ # Install mise (SDK manager) for language toolchains. RUN curl https://mise.run | sh @@ -51,11 +58,7 @@ RUN if MISE_JAVA=$(mise where java 2>/dev/null); then \ } >> "$HOME/.bashrc"; \ fi -# Pre-create ~/.claude so Docker bind-mounts (CLAUDE.md, agents/, commands/) -# don't cause it to be created as root-owned. -RUN mkdir -p /home/vscode/.claude - -RUN echo 'alias claude-yolo="claude --dangerously-skip-permissions"' >> /home/vscode/.bashrc +# __AGENT_DOCKER_HOME_PREP__ USER root ENTRYPOINT ["/usr/local/bin/app-init.sh"] diff --git a/cli/templates/devcontainer/compose-all.yml b/cli/templates/devcontainer/compose-all.yml index a8829e4..02b6506 100644 --- a/cli/templates/devcontainer/compose-all.yml +++ b/cli/templates/devcontainer/compose-all.yml @@ -19,8 +19,6 @@ services: # containers should never write to this. - mitmproxy-config:/mitmproxy-config:ro command: sleep infinity - environment: - - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 depends_on: wg-client: condition: service_healthy diff --git a/cli/templates/devcontainer/devcontainer.json b/cli/templates/devcontainer/devcontainer.json index 29d3ae4..3f9e6c9 100644 --- a/cli/templates/devcontainer/devcontainer.json +++ b/cli/templates/devcontainer/devcontainer.json @@ -20,7 +20,7 @@ "customizations": { "vscode": { "extensions": [ - "anthropic.claude-code", + // __AGENT_EXTENSION__ "github.vscode-pull-request-github", // __STACK_EXTENSIONS__ ], @@ -37,15 +37,7 @@ // For maximum protection, also set this in your host user // settings (workspace settings could theoretically override it). "terminal.integrated.allowLocalTerminal": false, - // Sandcat provides the security boundary (network isolation, - // secret substitution, iptables kill-switch), so permission - // prompts inside the container add friction without meaningful - // security benefit. Remove these if you prefer interactive - // permission approval. - "claudeCode.allowDangerouslySkipPermissions": true, - "claudeCode.initialPermissionMode": "bypassPermissions", - // Optional: override the default Claude model. - "claudeCode.selectedModel": "opus" + // __AGENT_SETTINGS__ } } } diff --git a/cli/templates/devcontainer/sandcat/compose-proxy.yml b/cli/templates/devcontainer/sandcat/compose-proxy.yml index 53fdd3e..73535a5 100644 --- a/cli/templates/devcontainer/sandcat/compose-proxy.yml +++ b/cli/templates/devcontainer/sandcat/compose-proxy.yml @@ -23,12 +23,13 @@ services: mitmproxy: image: mitmproxy/mitmproxy:latest - command: mitmweb --mode wireguard --web-host 0.0.0.0 --set web_password=mitmproxy -s /scripts/mitmproxy_addon.py + 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__ ports: - "8081" # mitmweb UI; host port assigned dynamically to avoid conflicts volumes: - mitmproxy-config:/home/mitmproxy/.mitmproxy - - ./scripts/mitmproxy_addon.py:/scripts/mitmproxy_addon.py:ro + - ./scripts/__AGENT_MITM_ADDON__:/scripts/__AGENT_MITM_ADDON__:ro + - ./scripts/mitmproxy_addon_common.py:/scripts/mitmproxy_addon_common.py:ro - ~/.config/sandcat/settings.json:/config/settings.json:ro healthcheck: test: ["CMD", "sh", "-c", "test -f /home/mitmproxy/.mitmproxy/wireguard.conf && test -f /home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem"] diff --git a/cli/templates/devcontainer/sandcat/scripts/app-init.sh b/cli/templates/devcontainer/sandcat/scripts/app-init.sh index ba74cbb..20b14d1 100644 --- a/cli/templates/devcontainer/sandcat/scripts/app-init.sh +++ b/cli/templates/devcontainer/sandcat/scripts/app-init.sh @@ -25,7 +25,34 @@ update-ca-certificates # Point it at the mitmproxy CA so TLS verification works for Node-based # tools (e.g. Anthropic SDK). export NODE_EXTRA_CA_CERTS="$CA_CERT" -echo "export NODE_EXTRA_CA_CERTS=\"$CA_CERT\"" > /etc/profile.d/sandcat-node-ca.sh + +# Force Node.js to use the system CA store (via OpenSSL) instead of its +# bundled Mozilla roots. Required for tools that bundle their own Node.js +# binary where NODE_EXTRA_CA_CERTS alone may not suffice. +SANDCAT_NODE_OPTS="--use-openssl-ca" +export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$SANDCAT_NODE_OPTS" + +cat > /etc/profile.d/sandcat-node-ca.sh << NODEEOF +export NODE_EXTRA_CA_CERTS="/mitmproxy-config/mitmproxy-ca-cert.pem" +export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }$SANDCAT_NODE_OPTS" +NODEEOF + +# Some CLIs don't consistently use the system trust store in all code paths. +# Export common CA-related env vars so TLS clients prefer the updated Debian +# CA bundle (which now includes the mitmproxy CA). +CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt" +export SSL_CERT_FILE="$CA_BUNDLE" +export SSL_CERT_DIR="/etc/ssl/certs" +export CURL_CA_BUNDLE="$CA_BUNDLE" +export REQUESTS_CA_BUNDLE="$CA_BUNDLE" +export GIT_SSL_CAINFO="$CA_BUNDLE" +cat > /etc/profile.d/sandcat-ca.sh << CAEOF +export SSL_CERT_FILE="$CA_BUNDLE" +export SSL_CERT_DIR="/etc/ssl/certs" +export CURL_CA_BUNDLE="$CA_BUNDLE" +export REQUESTS_CA_BUNDLE="$CA_BUNDLE" +export GIT_SSL_CAINFO="$CA_BUNDLE" +CAEOF # GPG keys are not forwarded into the container (credential isolation), # so commit signing would always fail. Git env vars have the highest @@ -53,8 +80,10 @@ else echo "No $SANDCAT_ENV found — env vars and secret substitution disabled" fi -# Run vscode-user tasks: git identity, Java trust store, Claude Code update. -su - vscode -c /usr/local/bin/app-user-init.sh +# Run vscode-user tasks in a login shell so user profile customizations +# (PATH/NVM/direnv, etc.) are applied. Re-source sandcat.env inside that +# shell so app-user-init still receives sandcat placeholders and env vars. +su - vscode -c '. /mitmproxy-config/sandcat.env 2>/dev/null || true; /usr/local/bin/app-user-init.sh' # Source all sandcat profile.d scripts from /etc/bash.bashrc so env vars # are available in non-login shells (e.g. VS Code integrated terminals). diff --git a/cli/templates/devcontainer/sandcat/scripts/app-user-init.sh b/cli/templates/devcontainer/sandcat/scripts/app-user-init.sh index 5aea7bb..38d8dba 100644 --- a/cli/templates/devcontainer/sandcat/scripts/app-user-init.sh +++ b/cli/templates/devcontainer/sandcat/scripts/app-user-init.sh @@ -6,6 +6,10 @@ # set -e +# app-init uses `su - vscode` (login shell). Keep HOME explicit so git config +# and related tools always write to the expected location. +export HOME="/home/vscode" + if [ -n "${GIT_USER_NAME:-}" ]; then git config --global user.name "$GIT_USER_NAME" fi @@ -26,6 +30,15 @@ git config --global commit.gpgsign false git config --global --replace-all url."https://github.com/".insteadOf "git@github.com:" "git@github.com:" git config --global --replace-all url."https://github.com/".insteadOf "ssh://git@github.com/" "ssh://git@github.com/" +# Mark mounted workspaces as safe Git directories to avoid +# "detected dubious ownership" errors in devcontainers. +for ws_dir in /workspaces/*; do + [ -d "$ws_dir" ] || continue + if ! git config --global --get-all safe.directory | grep -Fx "$ws_dir" >/dev/null 2>&1; then + git config --global --add safe.directory "$ws_dir" + fi +done + # If Java is installed (via mise), import the mitmproxy CA into Java's trust # store. Java uses its own cacerts and ignores the system CA store. CA_CERT="/mitmproxy-config/mitmproxy-ca-cert.pem" @@ -80,13 +93,5 @@ EOFJSON fi fi -# Seed the onboarding flag so Claude Code uses the API key without interactive -# setup. Only written when the user configured an ANTHROPIC_API_KEY secret. -if [ -n "${ANTHROPIC_API_KEY:-}" ]; then - echo '{"hasCompletedOnboarding":true}' > "$HOME/.claude.json" -fi - -# Claude Code is installed at build time (Dockerfile.app). -# Background update so it doesn't block startup. -(claude install >/dev/null 2>&1 &) +__AGENT_USER_INIT__ diff --git a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon.py b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon.py deleted file mode 100644 index 0e401b9..0000000 --- a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon.py +++ /dev/null @@ -1,271 +0,0 @@ -""" -mitmproxy addon: network access rules and secret substitution. - -Loaded via: mitmweb -s /scripts/mitmproxy_addon.py - -On startup, reads settings from up to three layers (lowest to highest -precedence): user (~/.config/sandcat/settings.json), project -(.sandcat/settings.json), and local (.sandcat/settings.local.json). -Env vars and secrets are merged (higher precedence wins on conflict). -Network rules are concatenated (highest precedence first). - -Network rules are evaluated top-to-bottom, first match wins, default deny. -Secret placeholders are replaced with real values only for allowed hosts. -""" - -import json -import logging -import os -import re -import subprocess -import sys -from fnmatch import fnmatch - -from mitmproxy import ctx, http, dns - -_VALID_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") - -# Settings layers, lowest to highest precedence. -SETTINGS_PATHS = [ - "/config/settings.json", # user: ~/.config/sandcat/settings.json - "/config/project/settings.json", # project: .sandcat/settings.json - "/config/project/settings.local.json", # local: .sandcat/settings.local.json -] -SANDCAT_ENV_PATH = "/home/mitmproxy/.mitmproxy/sandcat.env" - -logger = logging.getLogger(__name__) - -class SandcatAddon: - def __init__(self): - self.secrets: dict[str, dict] = {} # name -> {value, hosts, placeholder} - self.network_rules: list[dict] = [] - self.env: dict[str, str] = {} # non-secret env vars - - def load(self, loader): - layers = [] - for path in SETTINGS_PATHS: - if os.path.isfile(path): - with open(path) as f: - layers.append(json.load(f)) - - if not layers: - logger.info("No settings files found — addon disabled") - return - - merged = self._merge_settings(layers) - - self._configure_op_token(merged.get("op_service_account_token")) - self.env = merged["env"] - self._load_secrets(merged["secrets"]) - self._load_network_rules(merged["network"]) - self._write_placeholders_env() - - ctx.log.info( - f"Loaded {len(self.env)} env var(s) and {len(self.secrets)} secret(s), wrote {SANDCAT_ENV_PATH}" - ) - - @staticmethod - def _configure_op_token(token: str | None): - """Set OP_SERVICE_ACCOUNT_TOKEN from settings if not already in the environment.""" - if token and "OP_SERVICE_ACCOUNT_TOKEN" not in os.environ: - os.environ["OP_SERVICE_ACCOUNT_TOKEN"] = token - - @staticmethod - def _merge_settings(layers: list[dict]) -> dict: - """Merge settings from multiple layers (lowest to highest precedence). - - - env: dict merge, higher precedence overwrites. - - secrets: dict merge, higher precedence overwrites. - - network: concatenated, highest precedence first. - - op_service_account_token: highest precedence non-empty value wins. - """ - env: dict[str, str] = {} - secrets: dict[str, dict] = {} - network: list[dict] = [] - op_token: str | None = None - - for layer in layers: - env.update(layer.get("env", {})) - secrets.update(layer.get("secrets", {})) - layer_token = layer.get("op_service_account_token") - if layer_token: - op_token = layer_token - - # Network rules: highest-precedence layer's rules come first. - for layer in reversed(layers): - network.extend(layer.get("network", [])) - - return {"env": env, "secrets": secrets, "network": network, - "op_service_account_token": op_token} - - def _load_secrets(self, raw_secrets: dict): - for name, entry in raw_secrets.items(): - placeholder = f"SANDCAT_PLACEHOLDER_{name}" - try: - value = self._resolve_secret_value(name, entry) - except (RuntimeError, ValueError) as e: - ctx.log.warn(str(e)) - print(f"WARNING: {e}", file=sys.stderr) - value = "" - self.secrets[name] = { - "value": value, - "hosts": entry.get("hosts", []), - "placeholder": placeholder, - } - - @staticmethod - def _resolve_secret_value(name: str, entry: dict) -> str: - """Resolve a secret value from either a plain 'value' or a 1Password 'op' reference.""" - has_value = "value" in entry - has_op = "op" in entry - - if has_value and has_op: - raise ValueError( - f"Secret {name!r}: specify either 'value' or 'op', not both" - ) - if not has_value and not has_op: - raise ValueError( - f"Secret {name!r}: must specify either 'value' or 'op'" - ) - - if has_value: - return entry["value"] - - op_ref = entry["op"] - if not op_ref.startswith("op://"): - raise ValueError( - f"Secret {name!r}: 'op' value must start with 'op://', got {op_ref!r}" - ) - - try: - result = subprocess.run( - ["op", "read", op_ref], - capture_output=True, text=True, timeout=30, - ) - except FileNotFoundError: - raise RuntimeError( - f"Secret {name!r}: 'op' CLI not found. " - "Install 1Password CLI to use op:// references." - ) from None - - if result.returncode != 0: - stderr = result.stderr.strip() - raise RuntimeError( - f"Secret {name!r}: 'op read' failed: {stderr}" - ) - - return result.stdout.strip() - - def _load_network_rules(self, raw_rules: list): - self.network_rules = raw_rules - ctx.log.info(f"Loaded {len(self.network_rules)} network rule(s)") - - @staticmethod - def _shell_escape(value: str) -> str: - """Escape a string for safe inclusion inside double quotes in shell.""" - return (value - .replace("\\", "\\\\") - .replace('"', '\\"') - .replace("$", "\\$") - .replace("`", "\\`") - .replace("\n", "\\n")) - - @staticmethod - def _validate_env_name(name: str): - """Raise ValueError if name is not a valid shell variable name.""" - if not _VALID_ENV_NAME.match(name): - raise ValueError(f"Invalid env var name: {name!r}") - - def _write_placeholders_env(self): - lines = [] - # Non-secret env vars (e.g. git identity) — passed through as-is. - for name, value in self.env.items(): - self._validate_env_name(name) - lines.append(f'export {name}="{self._shell_escape(value)}"') - for name, entry in self.secrets.items(): - self._validate_env_name(name) - lines.append(f'export {name}="{self._shell_escape(entry["placeholder"])}"') - with open(SANDCAT_ENV_PATH, "w") as f: - f.write("\n".join(lines) + "\n") - - def _is_request_allowed(self, method: str | None, host: str) -> bool: - host = host.lower().rstrip(".") - for rule in self.network_rules: - if not fnmatch(host, rule["host"].lower()): - continue - rule_method = rule.get("method") - if rule_method is not None and method is not None and rule_method.upper() != method.upper(): - continue - return rule["action"] == "allow" - return False # default deny - - def _substitute_secrets(self, flow: http.HTTPFlow): - host = flow.request.pretty_host.lower() - - for name, entry in self.secrets.items(): - placeholder = entry["placeholder"] - value = entry["value"] - allowed_hosts = entry["hosts"] - - present = ( - placeholder in flow.request.url - or placeholder in str(flow.request.headers) - or ( - flow.request.content - and placeholder.encode() in flow.request.content - ) - ) - - if not present: - continue - - # Leak detection: block if secret going to disallowed host - if not any(fnmatch(host, pattern.lower()) for pattern in allowed_hosts): - flow.response = http.Response.make( - 403, - f"Blocked: secret {name!r} not allowed for host {host!r}\n".encode(), - {"Content-Type": "text/plain"}, - ) - ctx.log.warn( - f"Blocked secret {name!r} leak to disallowed host {host!r}" - ) - return - - if placeholder in flow.request.url: - flow.request.url = flow.request.url.replace(placeholder, value) - for k, v in flow.request.headers.items(): - if placeholder in v: - flow.request.headers[k] = v.replace(placeholder, value) - if flow.request.content and placeholder.encode() in flow.request.content: - flow.request.content = flow.request.content.replace( - placeholder.encode(), value.encode() - ) - - def request(self, flow: http.HTTPFlow): - method = flow.request.method - host = flow.request.pretty_host - - if not self._is_request_allowed(method, host): - flow.response = http.Response.make( - 403, - f"Blocked by network policy: {method} {host}\n".encode(), - {"Content-Type": "text/plain"}, - ) - ctx.log.warn(f"Network deny: {method} {host}") - return - - self._substitute_secrets(flow) - - def dns_request(self, flow: dns.DNSFlow): - question = flow.request.question - if question is None: - flow.response = flow.request.fail(dns.response_codes.REFUSED) - return - - host = question.name - if not self._is_request_allowed(None, host): - flow.response = flow.request.fail(dns.response_codes.REFUSED) - ctx.log.warn(f"DNS deny: {host}") - - -addons = [SandcatAddon()] diff --git a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_claude.py b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_claude.py new file mode 100644 index 0000000..117a968 --- /dev/null +++ b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_claude.py @@ -0,0 +1,22 @@ +""" +Claude-focused mitmproxy addon: network access rules and secret substitution. + +Loaded via: mitmweb -s /scripts/mitmproxy_addon_claude.py + +This is a thin wrapper around the shared :mod:`mitmproxy_addon_common` +library. Claude does not require streaming-aware handling, so the default +behaviour of the base ``SandcatAddon`` class is sufficient. + +On startup, reads settings from up to three layers (lowest to highest +precedence): user (``~/.config/sandcat/settings.json``), project +(``.sandcat/settings.json``), and local (``.sandcat/settings.local.json``). +Env vars and secrets are merged (higher precedence wins on conflict). +Network rules are concatenated (highest precedence first). + +Network rules are evaluated top-to-bottom, first match wins, default deny. +Secret placeholders are replaced with real values only for allowed hosts. +""" + +from mitmproxy_addon_common import SandcatAddon + +addons = [SandcatAddon()] diff --git a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py new file mode 100644 index 0000000..eb873ab --- /dev/null +++ b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py @@ -0,0 +1,428 @@ +""" +Shared mitmproxy addon library for sandcat. + +Provides a base ``SandcatAddon`` class that agent-specific addons subclass. +The base implements all behavior that is identical across agents: + + - Settings layer loading and merging (user / project / local). + - 1Password (``op://``) reference resolution. + - Network policy evaluation (top-to-bottom, first match wins, default deny). + - Secret substitution in URL, headers, optional Basic Auth, and body. + - ``sandcat.env`` file generation that exports placeholders for the agent. + +Agent variants override a small set of hook methods to customise behaviour: + + - ``_on_settings_merged(merged)`` — read agent-specific settings keys. + - ``_normalize_secret_value(value)`` — sanitize a resolved secret value. + - ``_is_streaming_request(flow) -> bool`` — keep the body opaque if True. + - ``_prepare_streaming_request(flow)`` — per-request streaming setup. + - ``_normalize_authorization_header(v)`` — sanitize the ``Authorization`` + header after substitution. + - ``_basic_auth_contains_placeholder(auth_header, placeholder) -> bool``. + - ``_replace_placeholder_in_basic_auth(auth_header, placeholder, value)``. + - ``_is_textual_content_type(ct) -> bool`` — body substitution gate. + +The defaults are tuned to match the simplest "Claude" behaviour (no streaming, +no Basic Auth handling, body substitution always permitted). +""" + +import base64 +import binascii +import hashlib +import json +import logging +import os +import re +import subprocess +import sys +from fnmatch import fnmatch + +from mitmproxy import ctx, dns, http + +_VALID_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +# Settings layers, lowest to highest precedence. +SETTINGS_PATHS = [ + "/config/settings.json", # user: ~/.config/sandcat/settings.json + "/config/project/settings.json", # project: .sandcat/settings.json + "/config/project/settings.local.json", # local: .sandcat/settings.local.json +] +SANDCAT_ENV_PATH = "/home/mitmproxy/.mitmproxy/sandcat.env" + +logger = logging.getLogger(__name__) + + +class SandcatAddon: + """Base sandcat addon: network policy + secret substitution.""" + + def __init__(self): + self.secrets: dict[str, dict] = {} # name -> {value, hosts, placeholder} + self.network_rules: list[dict] = [] + self.env: dict[str, str] = {} # non-secret env vars (e.g. git identity) + self.debug_enabled = False # subclasses may flip this in _on_settings_merged + + # ------------------------------------------------------------------ load + + def load(self, loader): + layers = [] + for path in SETTINGS_PATHS: + if os.path.isfile(path): + with open(path) as f: + layers.append(json.load(f)) + + if not layers: + logger.info("No settings files found — addon disabled") + return + + merged = self._merge_settings(layers) + self._on_settings_merged(merged) + + self._configure_op_token(merged.get("op_service_account_token")) + self.env = merged["env"] + self._load_secrets(merged["secrets"]) + self._load_network_rules(merged["network"]) + self._write_placeholders_env() + + ctx.log.info( + f"Loaded {len(self.env)} env var(s) and {len(self.secrets)} secret(s), " + f"wrote {SANDCAT_ENV_PATH}" + ) + + def _on_settings_merged(self, merged: dict): + """Hook: subclasses may inspect merged settings (e.g., feature flags).""" + pass + + @staticmethod + def _configure_op_token(token: str | None): + """Set OP_SERVICE_ACCOUNT_TOKEN from settings if not already in the environment.""" + if token and "OP_SERVICE_ACCOUNT_TOKEN" not in os.environ: + os.environ["OP_SERVICE_ACCOUNT_TOKEN"] = token + + @staticmethod + def _merge_settings(layers: list[dict]) -> dict: + """Merge settings from multiple layers (lowest to highest precedence). + + - env: dict merge, higher precedence overwrites. + - secrets: dict merge, higher precedence overwrites. + - network: concatenated, highest precedence first (top-to-bottom matching). + - op_service_account_token: highest precedence non-empty value wins. + """ + env: dict[str, str] = {} + secrets: dict[str, dict] = {} + network: list[dict] = [] + op_token: str | None = None + + for layer in layers: + env.update(layer.get("env", {})) + secrets.update(layer.get("secrets", {})) + layer_token = layer.get("op_service_account_token") + if layer_token: + op_token = layer_token + + # Network rules: highest-precedence layer's rules come first. + for layer in reversed(layers): + network.extend(layer.get("network", [])) + + return { + "env": env, + "secrets": secrets, + "network": network, + "op_service_account_token": op_token, + } + + # --------------------------------------------------------------- secrets + + def _load_secrets(self, raw_secrets: dict): + for name, entry in raw_secrets.items(): + placeholder = f"SANDCAT_PLACEHOLDER_{name}" + try: + value = self._resolve_secret_value(name, entry) + except (RuntimeError, ValueError) as e: + ctx.log.warn(str(e)) + print(f"WARNING: {e}", file=sys.stderr) + value = "" + self.secrets[name] = { + "value": self._normalize_secret_value(value), + "hosts": entry.get("hosts", []), + "placeholder": placeholder, + } + + @classmethod + def _resolve_secret_value(cls, name: str, entry: dict) -> str: + """Resolve a secret from either a plain ``value`` or a 1Password ``op`` reference.""" + has_value = "value" in entry + has_op = "op" in entry + + if has_value and has_op: + raise ValueError( + f"Secret {name!r}: specify either 'value' or 'op', not both" + ) + if not has_value and not has_op: + raise ValueError( + f"Secret {name!r}: must specify either 'value' or 'op'" + ) + + if has_value: + return cls._normalize_secret_value(entry["value"]) + + op_ref = entry["op"] + if not op_ref.startswith("op://"): + raise ValueError( + f"Secret {name!r}: 'op' value must start with 'op://', got {op_ref!r}" + ) + + try: + result = subprocess.run( + ["op", "read", op_ref], + capture_output=True, text=True, timeout=30, + ) + except FileNotFoundError: + raise RuntimeError( + f"Secret {name!r}: 'op' CLI not found. " + "Install 1Password CLI to use op:// references." + ) from None + + if result.returncode != 0: + stderr = result.stderr.strip() + raise RuntimeError(f"Secret {name!r}: 'op read' failed: {stderr}") + + return cls._normalize_secret_value(result.stdout.strip()) + + @staticmethod + def _normalize_secret_value(value) -> str: + """Default: pass-through. Subclasses may strip whitespace / BOM.""" + return "" if value is None else value + + # --------------------------------------------------------------- network + + def _load_network_rules(self, raw_rules: list): + self.network_rules = raw_rules + ctx.log.info(f"Loaded {len(self.network_rules)} network rule(s)") + + def _find_matching_rule(self, method: str | None, host: str) -> dict | None: + host = host.lower().rstrip(".") + for rule in self.network_rules: + if not fnmatch(host, rule["host"].lower()): + continue + rule_method = rule.get("method") + if rule_method is not None and method is not None and rule_method.upper() != method.upper(): + continue + return rule + return None + + def _is_request_allowed(self, method: str | None, host: str) -> bool: + rule = self._find_matching_rule(method, host) + return rule is not None and rule.get("action") == "allow" + + # ----------------------------------------------------------- env writer + + @staticmethod + def _shell_escape(value: str) -> str: + """Escape a string for safe inclusion inside double quotes in shell.""" + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("$", "\\$") + .replace("`", "\\`") + .replace("\n", "\\n") + ) + + @staticmethod + def _validate_env_name(name: str): + """Raise ValueError if name is not a valid shell variable name.""" + if not _VALID_ENV_NAME.match(name): + raise ValueError(f"Invalid env var name: {name!r}") + + def _write_placeholders_env(self): + lines = [] + # Non-secret env vars (e.g. git identity) — passed through as-is. + for name, value in self.env.items(): + self._validate_env_name(name) + lines.append(f'export {name}="{self._shell_escape(value)}"') + for name, entry in self.secrets.items(): + self._validate_env_name(name) + lines.append(f'export {name}="{self._shell_escape(entry["placeholder"])}"') + with open(SANDCAT_ENV_PATH, "w") as f: + f.write("\n".join(lines) + "\n") + + # ---------------------------------------------------- substitution hooks + + def _is_streaming_request(self, flow: http.HTTPFlow) -> bool: + """Return True for requests whose body must remain opaque (no mutation).""" + return False + + def _prepare_streaming_request(self, flow: http.HTTPFlow): + """Per-request streaming setup. Default: nothing to do.""" + pass + + def _normalize_authorization_header(self, value: str) -> str: + """Sanitize the ``Authorization`` header value after substitution.""" + return value + + @staticmethod + def _basic_auth_contains_placeholder(auth_header: str | None, placeholder: str) -> bool: + """Default: no Basic Auth substitution support.""" + return False + + @staticmethod + def _replace_placeholder_in_basic_auth( + auth_header: str | None, placeholder: str, value: str + ) -> tuple[str | None, bool]: + """Default: do not touch Basic Auth headers. Returns (header, replaced=False).""" + return auth_header, False + + @staticmethod + def _is_textual_content_type(content_type: str | None) -> bool: + """Default: any content type is eligible for body substitution.""" + return True + + # ---------------------------------------------------- debug helpers (opt-in) + + def _debug(self, message: str): + if self.debug_enabled: + ctx.log.info(f"[sandcat-debug] {message}") + + @staticmethod + def _is_truthy(value) -> bool: + if value is None: + return False + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + @staticmethod + def _auth_debug_summary(header: str | None) -> str: + """Safe fingerprint for logs: no raw secrets; detects odd bytes (401 debugging).""" + if not header: + return "authorization=" + + h = header.strip() + low = h.lower() + if low.startswith("bearer "): + tok = h[7:].strip() + fp = hashlib.sha256(tok.encode("utf-8")).hexdigest()[:12] + bad = [hex(ord(c)) for c in tok if ord(c) < 32 or ord(c) == 127] + extra = f" ctrl_bytes={bad[:8]}" if bad else "" + return f"bearer len={len(tok)} sha256_12={fp}{extra}" + + if low.startswith("basic "): + raw = h[6:].strip() + try: + dec = base64.b64decode(raw).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError) as e: + return f"basic decode_err={e!r}" + fp = hashlib.sha256(dec.encode("utf-8")).hexdigest()[:12] + bad = [hex(ord(c)) for c in dec if ord(c) < 32 and c not in "\t"] + extra = f" ctrl_bytes={bad[:8]}" if bad else "" + return f"basic decoded_len={len(dec)} sha256_12={fp}{extra}" + + return f"other len={len(h)}" + + # ------------------------------------------------------ secret substitution + + def _substitute_secrets(self, flow: http.HTTPFlow): + host = flow.request.pretty_host.lower() + is_streaming = self._is_streaming_request(flow) + + pre_auth = ( + self._auth_debug_summary(flow.request.headers.get("authorization")) + if self.debug_enabled else "" + ) + + for name, entry in self.secrets.items(): + placeholder = entry["placeholder"] + value = entry["value"] + allowed_hosts = entry["hosts"] + auth_header = flow.request.headers.get("authorization", "") + + present = ( + placeholder in flow.request.url + or placeholder in str(flow.request.headers) + or self._basic_auth_contains_placeholder(auth_header, placeholder) + or ( + not is_streaming + and flow.request.content + and placeholder.encode("utf-8") in flow.request.content + ) + ) + + if not present: + continue + + # Leak detection: block if secret is going to a disallowed host. + if not any(fnmatch(host, pattern.lower()) for pattern in allowed_hosts): + flow.response = http.Response.make( + 403, + f"Blocked: secret {name!r} not allowed for host {host!r}\n".encode(), + {"Content-Type": "text/plain"}, + ) + ctx.log.warn(f"Blocked secret {name!r} leak to disallowed host {host!r}") + return + + if placeholder in flow.request.url: + flow.request.url = flow.request.url.replace(placeholder, value) + + for k, v in list(flow.request.headers.items()): + if placeholder in v: + new_v = v.replace(placeholder, value) + if k.lower() == "authorization": + new_v = self._normalize_authorization_header(new_v) + flow.request.headers[k] = new_v + + updated_auth, replaced_basic = self._replace_placeholder_in_basic_auth( + flow.request.headers.get("authorization", ""), placeholder, value + ) + if replaced_basic and updated_auth is not None: + flow.request.headers["authorization"] = updated_auth + + if is_streaming: + continue + + if flow.request.content and placeholder.encode("utf-8") in flow.request.content: + content_type = flow.request.headers.get("content-type", "") + if self._is_textual_content_type(content_type): + flow.request.content = flow.request.content.replace( + placeholder.encode("utf-8"), value.encode("utf-8") + ) + + if self.debug_enabled: + post_auth = self._auth_debug_summary(flow.request.headers.get("authorization")) + self._debug( + f"{flow.request.method} {flow.request.pretty_host}{flow.request.path} " + f"streaming={is_streaming} auth_pre={pre_auth} auth_post={post_auth}" + ) + + # -------------------------------------------------------------- handlers + + def request(self, flow: http.HTTPFlow): + method = flow.request.method + host = flow.request.pretty_host + + if not self._is_request_allowed(method, host): + flow.response = http.Response.make( + 403, + f"Blocked by network policy: {method} {host}\n".encode(), + {"Content-Type": "text/plain"}, + ) + ctx.log.warn(f"Network deny: {method} {host}") + return + + if self._is_streaming_request(flow): + self._prepare_streaming_request(flow) + + self._substitute_secrets(flow) + + def responseheaders(self, flow: http.HTTPFlow): + if self._is_streaming_request(flow): + flow.response.stream = True + + def dns_request(self, flow: dns.DNSFlow): + question = flow.request.question + if question is None: + flow.response = flow.request.fail(dns.response_codes.REFUSED) + return + + host = question.name + if not self._is_request_allowed(None, host): + flow.response = flow.request.fail(dns.response_codes.REFUSED) + ctx.log.warn(f"DNS deny: {host}") diff --git a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_cursor.py b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_cursor.py new file mode 100644 index 0000000..5f1af1c --- /dev/null +++ b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_cursor.py @@ -0,0 +1,154 @@ +""" +Cursor-focused mitmproxy addon: network policy + secret substitution. + +This variant builds on the shared :mod:`mitmproxy_addon_common` library and +adds Cursor-specific behaviour: + + - Cursor Connect streaming traffic stays opaque (no body mutation). + Streaming detection is *path-only* (``/agent.v1.AgentService/Run*``, + ``/aiserver.v1.RepositoryService/...``) — we deliberately do not honour + a client-supplied ``content-type: application/connect+proto`` header, + because that would let any request with the right header bypass body + placeholder substitution and content-based leak detection. + - Body substitution applies only to textual content types (JSON, XML, + form-encoded, ``+json`` / ``+xml`` suffixes). Cursor's binary protobuf + payloads are not safe to mutate by-byte, and Cursor secret placeholders + only ride in URL/header/Basic-Auth surfaces anyway. + - Placeholder substitution still applies to URL, headers, and Basic Auth. + - Resolved secret values are stripped (whitespace / BOM) — common 401 cause. + - The ``Authorization`` header is normalised after substitution. + - Optional debug logging via the ``SANDCAT_MITM_DEBUG`` env var or setting. +""" + +import base64 +import binascii +import os + +from mitmproxy import http + +from mitmproxy_addon_common import SandcatAddon as _SandcatAddonBase + + +class SandcatAddon(_SandcatAddonBase): + """Cursor-specific overrides on top of the shared addon library.""" + + # ---------------------------------------------------------- settings hook + + def _on_settings_merged(self, merged: dict): + raw_debug = merged.get("env", {}).get( + "SANDCAT_MITM_DEBUG", os.environ.get("SANDCAT_MITM_DEBUG", "") + ) + self.debug_enabled = self._is_truthy(raw_debug) + + # --------------------------------------------------- secret normalization + + @staticmethod + def _normalize_secret_value(value) -> str: + """Strip accidental whitespace/newlines from JSON or CLI output (common 401 cause).""" + if value is None: + return "" + s = str(value).strip() + # Strip UTF-8 BOM from pasted JSON / editor exports (invisible in most UIs). + return s.lstrip("\ufeff") + + # --------------------------------------- authorization header normalization + + def _normalize_authorization_header(self, value: str) -> str: + """Trim token after substitution; preserve the client's Bearer scheme spelling + (RFC 7235 is case-insensitive, but some stacks are picky).""" + v = value.strip() + if len(v) >= 7 and v[:7].lower() == "bearer ": + token = v[7:].strip() + scheme = v[:6] # preserve e.g. Bearer / bearer / BEARER + return f"{scheme} {token}" + return v + + # ---------------------------------------- streaming detection / preparation + + @staticmethod + def _is_cursor_host(host: str) -> bool: + host = host.lower().rstrip(".") + return ( + host == "cursor.sh" + or host == "cursor.com" + or host.endswith(".cursor.sh") + or host.endswith(".cursor.com") + ) + + def _is_streaming_request(self, flow: http.HTTPFlow) -> bool: + # Streaming detection is path-only by design — see module docstring. + host = flow.request.pretty_host.lower() + if not self._is_cursor_host(host): + return False + + path = flow.request.path + return ( + path.startswith("/agent.v1.AgentService/Run") + or path.startswith("/agent.v1.AgentService/RunSSE") + or path.startswith("/aiserver.v1.RepositoryService/") + ) + + @staticmethod + def _prepare_streaming_request(flow: http.HTTPFlow): + flow.request.headers["accept-encoding"] = "identity" + flow.request.stream = True + + # ---------------------------------------------- textual content-type gate + + @staticmethod + def _is_textual_content_type(content_type: str | None) -> bool: + if not content_type: + return False + media_type = content_type.split(";", 1)[0].strip().lower() + return ( + media_type.startswith("text/") + or media_type + in { + "application/json", + "application/x-www-form-urlencoded", + "application/xml", + "application/javascript", + "application/graphql", + } + or media_type.endswith("+json") + or media_type.endswith("+xml") + ) + + # ----------------------------------------------------- basic auth helpers + + @staticmethod + def _basic_auth_contains_placeholder(auth_header: str | None, placeholder: str) -> bool: + if not auth_header or not auth_header.lower().startswith("basic "): + return False + encoded = auth_header.split(" ", 1)[1].strip() + if not encoded: + return False + try: + decoded = base64.b64decode(encoded).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError): + return False + return placeholder in decoded + + @staticmethod + def _replace_placeholder_in_basic_auth( + auth_header: str | None, placeholder: str, value: str + ) -> tuple[str | None, bool]: + if not auth_header or not auth_header.lower().startswith("basic "): + return auth_header, False + encoded = auth_header.split(" ", 1)[1].strip() + if not encoded: + return auth_header, False + try: + decoded = base64.b64decode(encoded).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError): + return auth_header, False + if placeholder not in decoded: + return auth_header, False + replaced = decoded.replace(placeholder, value) + # Trim only outer CR/LF; avoids invisible line-ending damage from clients/editors. + replaced = replaced.strip("\r\n") + new_encoded = base64.b64encode(replaced.encode("utf-8")).decode("ascii") + return f"Basic {new_encoded}", True + + +addons = [SandcatAddon()] diff --git a/cli/templates/settings-user.json b/cli/templates/settings-user-claude.json similarity index 100% rename from cli/templates/settings-user.json rename to cli/templates/settings-user-claude.json diff --git a/cli/templates/settings-user-cursor.json b/cli/templates/settings-user-cursor.json new file mode 100644 index 0000000..d06b148 --- /dev/null +++ b/cli/templates/settings-user-cursor.json @@ -0,0 +1,20 @@ +{ + "env": {}, + "secrets": { + "CURSOR_API_KEY": { + "value": "", + "hosts": ["api.cursor.sh", "api2.cursor.sh", "*.cursor.sh", "*.cursor.com"] + }, + "GITHUB_TOKEN": { + "value": "", + "hosts": ["github.com", "*.github.com", "*.githubusercontent.com"] + } + }, + "network": [ + {"action": "allow", "host": "*.github.com"}, + {"action": "allow", "host": "github.com"}, + {"action": "allow", "host": "*.githubusercontent.com"}, + {"action": "allow", "host": "*.cursor.sh"}, + {"action": "allow", "host": "*.cursor.com"} + ] +} diff --git a/cli/test/agents/agents.bats b/cli/test/agents/agents.bats new file mode 100755 index 0000000..71bb708 --- /dev/null +++ b/cli/test/agents/agents.bats @@ -0,0 +1,312 @@ +#!/usr/bin/env bats +# shellcheck disable=SC2030,SC2031 + +# Direct unit tests for the sct_agent_* dispatcher contract in +# cli/lib/agents.bash. These tests lock the shape of the dispatch table — +# the next agent integration must add a case branch here without changing +# existing behaviour. +# +# Each test exercises three inputs: +# - claude — primary supported agent +# - cursor — second supported agent (added in the Cursor PR) +# - unknown — unsupported value, exercising the `*` fallback + +setup() { + load test_helper + # shellcheck source=../../lib/agents.bash + source "$SCT_LIBDIR/agents.bash" +} + +# ---------------------------------------------------------------- discovery + +@test "sct_available_agents lists claude and cursor" { + run sct_available_agents + assert_success + assert_output "claude cursor" +} + +@test "sct_is_valid_agent accepts known agents" { + run sct_is_valid_agent claude + assert_success + run sct_is_valid_agent cursor + assert_success +} + +@test "sct_is_valid_agent rejects unknown agent" { + run sct_is_valid_agent unknown + assert_failure +} + +# ----------------------------------------------------- mount env var dispatch + +@test "sct_agent_mount_env_var: claude" { + run sct_agent_mount_env_var claude + assert_output "SANDCAT_MOUNT_CLAUDE_CONFIG" +} + +@test "sct_agent_mount_env_var: cursor" { + run sct_agent_mount_env_var cursor + assert_output "SANDCAT_MOUNT_CURSOR_CONFIG" +} + +@test "sct_agent_mount_env_var: unknown returns empty" { + run sct_agent_mount_env_var unknown + assert_output "" +} + +# ---------------------------------------------------- host config path lists + +@test "sct_agent_host_config_paths: claude lists ~/.claude entries" { + run sct_agent_host_config_paths claude + assert_output --partial '$HOME/.claude/agents/' + assert_output --partial '$HOME/.claude/commands/' + assert_output --partial '$HOME/.claude/CLAUDE.md' +} + +@test "sct_agent_host_config_paths: cursor lists ~/.cursor entries" { + run sct_agent_host_config_paths cursor + assert_output --partial '$HOME/.cursor/rules/' + assert_output --partial '$HOME/.cursor/skills/' + assert_output --partial '$HOME/.cursor/AGENTS.md' +} + +@test "sct_agent_host_config_paths: unknown returns empty" { + run sct_agent_host_config_paths unknown + assert_output "" +} + +# ---------------------------------------------- ensure_host_agent_config_paths + +@test "ensure_host_agent_config_paths: creates claude paths under HOME" { + export HOME="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME" + + run ensure_host_agent_config_paths claude + assert_success + + [[ -d "$HOME/.claude/agents" ]] + [[ -d "$HOME/.claude/commands" ]] + [[ -f "$HOME/.claude/CLAUDE.md" ]] +} + +@test "ensure_host_agent_config_paths: skips when SANDCAT_MOUNT_CLAUDE_CONFIG=false" { + export HOME="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME" + export SANDCAT_MOUNT_CLAUDE_CONFIG=false + + run ensure_host_agent_config_paths claude + assert_success + + [[ ! -d "$HOME/.claude" ]] +} + +@test "ensure_host_agent_config_paths: no-op for unknown agent" { + export HOME="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME" + + run ensure_host_agent_config_paths unknown + assert_success +} + +# ----------------------------------------------------------- API key help + +@test "sct_agent_api_key_help: claude" { + run sct_agent_api_key_help claude + assert_output --partial "ANTHROPIC_API_KEY" +} + +@test "sct_agent_api_key_help: cursor" { + run sct_agent_api_key_help cursor + assert_output --partial "CURSOR_API_KEY" +} + +@test "sct_agent_api_key_help: unknown falls back to anthropic line" { + run sct_agent_api_key_help unknown + assert_output --partial "ANTHROPIC_API_KEY" +} + +@test "sct_agent_op_api_key_help: claude" { + run sct_agent_op_api_key_help claude + assert_output --partial "ANTHROPIC_API_KEY" + assert_output --partial 'op://vault/Anthropic API Key/credential' +} + +@test "sct_agent_op_api_key_help: cursor" { + run sct_agent_op_api_key_help cursor + assert_output --partial "CURSOR_API_KEY" + assert_output --partial 'op://vault/Cursor API Key/credential' +} + +@test "sct_agent_op_api_key_help: unknown falls back to anthropic line" { + run sct_agent_op_api_key_help unknown + assert_output --partial "ANTHROPIC_API_KEY" + assert_output --partial 'op://vault/Anthropic API Key/credential' +} + +# --------------------------------------------------------- VS Code extension + +@test "sct_agent_vscode_extension: claude" { + run sct_agent_vscode_extension claude + assert_output "anthropic.claude-code" +} + +@test "sct_agent_vscode_extension: cursor" { + run sct_agent_vscode_extension cursor + assert_output "anysphere.cursor" +} + +@test "sct_agent_vscode_extension: unknown returns empty" { + run sct_agent_vscode_extension unknown + assert_output "" +} + +# --------------------------------------------------- devcontainer settings + +@test "sct_agent_devcontainer_settings_block: claude includes claudeCode keys" { + run sct_agent_devcontainer_settings_block claude + assert_output --partial "claudeCode.allowDangerouslySkipPermissions" +} + +@test "sct_agent_devcontainer_settings_block: cursor returns a placeholder note" { + run sct_agent_devcontainer_settings_block cursor + # Comment-only block — must not be empty (otherwise apply_template_placeholders + # will drop the placeholder line entirely, removing JSON context). + [[ -n "$output" ]] + assert_output --partial "Cursor" +} + +@test "sct_agent_devcontainer_settings_block: unknown returns empty" { + run sct_agent_devcontainer_settings_block unknown + assert_output "" +} + +# --------------------------------------------------- compose environment + +@test "sct_agent_compose_environment_entries: claude has CLAUDE_CODE flag" { + run sct_agent_compose_environment_entries claude + assert_output "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1" +} + +@test "sct_agent_compose_environment_entries: cursor returns empty" { + run sct_agent_compose_environment_entries cursor + assert_output "" +} + +@test "sct_agent_compose_environment_entries: unknown returns empty" { + run sct_agent_compose_environment_entries unknown + assert_output "" +} + +# --------------------------------------------------- Dockerfile install + +@test "sct_agent_docker_install_block: claude installs claude binary" { + run sct_agent_docker_install_block claude + assert_output --partial "claude.ai/install.sh" +} + +@test "sct_agent_docker_install_block: cursor installs cursor cli" { + run sct_agent_docker_install_block cursor + assert_output --partial "cursor.com/install" +} + +@test "sct_agent_docker_install_block: unknown returns empty" { + run sct_agent_docker_install_block unknown + assert_output "" +} + +# --------------------------------------------------- Dockerfile home prep + +@test "sct_agent_docker_home_prep_block: claude pre-creates ~/.claude" { + run sct_agent_docker_home_prep_block claude + assert_output --partial "/home/vscode/.claude" +} + +@test "sct_agent_docker_home_prep_block: cursor pre-creates ~/.cursor" { + run sct_agent_docker_home_prep_block cursor + assert_output --partial "/home/vscode/.cursor" +} + +@test "sct_agent_docker_home_prep_block: unknown returns empty" { + run sct_agent_docker_home_prep_block unknown + assert_output "" +} + +# --------------------------------------------------- user init bootstrap + +@test "sct_agent_user_init_block: claude seeds onboarding" { + run sct_agent_user_init_block claude + assert_output --partial "hasCompletedOnboarding" +} + +@test "sct_agent_user_init_block: cursor configures cli-config.json" { + run sct_agent_user_init_block cursor + assert_output --partial "cli-config.json" + assert_output --partial "useHttp1ForAgent" +} + +@test "sct_agent_user_init_block: unknown returns empty" { + run sct_agent_user_init_block unknown + assert_output "" +} + +# --------------------------------------------------- mitm streaming flags + +@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" +} + +@test "sct_agent_mitm_streaming_flags: claude returns empty" { + # Empty is intentional: leaving stream_large_bodies unset means mitmproxy + # buffers <1MB bodies, which is what the addon's body-content leak check + # relies on. + run sct_agent_mitm_streaming_flags claude + assert_output "" +} + +@test "sct_agent_mitm_streaming_flags: unknown returns empty" { + run sct_agent_mitm_streaming_flags unknown + assert_output "" +} + +# --------------------------------------------------- post user-settings hook + +@test "sct_agent_post_user_settings_hook: cursor calls ensure_cursor_user_settings_defaults when defined" { + # Stub the helper to record invocation; the hook must call it for cursor. + local marker="$BATS_TEST_TMPDIR/cursor-hook" + # shellcheck disable=SC2317 + ensure_cursor_user_settings_defaults() { touch "$marker"; } + export -f ensure_cursor_user_settings_defaults + + run sct_agent_post_user_settings_hook cursor + assert_success + [[ -f "$marker" ]] +} + +@test "sct_agent_post_user_settings_hook: claude is a no-op" { + # Even if the cursor helper is defined, the claude path must not call it. + local marker="$BATS_TEST_TMPDIR/cursor-hook" + # shellcheck disable=SC2317 + ensure_cursor_user_settings_defaults() { touch "$marker"; } + export -f ensure_cursor_user_settings_defaults + + run sct_agent_post_user_settings_hook claude + assert_success + [[ ! -f "$marker" ]] +} + +@test "sct_agent_post_user_settings_hook: unknown is a no-op" { + run sct_agent_post_user_settings_hook unknown + assert_success +} + +@test "sct_agent_post_user_settings_hook: cursor with helper missing is a no-op" { + # When init isn't sourced, the helper isn't defined. The hook must still + # succeed (declare -F guard) so unit-testing agents.bash standalone works. + unset -f ensure_cursor_user_settings_defaults 2>/dev/null || true + run sct_agent_post_user_settings_hook cursor + assert_success +} diff --git a/cli/test/agents/test_helper.bash b/cli/test/agents/test_helper.bash new file mode 100644 index 0000000..49c8598 --- /dev/null +++ b/cli/test/agents/test_helper.bash @@ -0,0 +1,21 @@ +#!/bin/bash + +bats_require_minimum_version 1.5.0 + +if shopt -s compat32 2>/dev/null; then + export BASH_COMPAT=3.2 +fi +set -uo pipefail +export SHELLOPTS + +SCT_ROOT="$BATS_TEST_DIRNAME/../.." + +BATS_LIB_PATH="$SCT_ROOT/support":${BATS_LIB_PATH-} + +bats_load_library bats-ext +bats_load_library bats-support +bats_load_library bats-assert +bats_load_library bats-mock-ext + +export SCT_ROOT +export SCT_LIBDIR="$SCT_ROOT/lib" diff --git a/cli/test/composefile/composefile.bats b/cli/test/composefile/composefile.bats index 7cdba1f..ded5e60 100644 --- a/cli/test/composefile/composefile.bats +++ b/cli/test/composefile/composefile.bats @@ -49,6 +49,22 @@ teardown() { yq -e '.services.agent.volumes[] | select(. == "${HOME}/.claude/commands:/home/vscode/.claude/commands:ro")' "$COMPOSE_FILE" } +@test "add_cursor_config_volumes adds AGENTS.md, rules, and skills" { + add_cursor_config_volumes "$COMPOSE_FILE" + + run yq '.services.agent.volumes | length' "$COMPOSE_FILE" + assert_output "4" + + # shellcheck disable=SC2016 + yq -e '.services.agent.volumes[] | select(. == "${HOME}/.cursor/AGENTS.md:/home/vscode/.cursor/AGENTS.md:ro")' "$COMPOSE_FILE" + + # shellcheck disable=SC2016 + yq -e '.services.agent.volumes[] | select(. == "${HOME}/.cursor/rules:/home/vscode/.cursor/rules:ro")' "$COMPOSE_FILE" + + # shellcheck disable=SC2016 + yq -e '.services.agent.volumes[] | select(. == "${HOME}/.cursor/skills:/home/vscode/.cursor/skills:ro")' "$COMPOSE_FILE" +} + @test "add_git_readonly_volume adds .git mount as read-only" { add_git_readonly_volume "$COMPOSE_FILE" @@ -237,3 +253,60 @@ EOF assert_customize_compose_file_common "$COMPOSE_FILE" } + +# shellcheck disable=SC2016 +@test "customize_compose_file dispatches to add_cursor_config_volumes for cursor agent" { + # We assert the dispatch (one Cursor-specific volume present) rather than + # re-listing every cursor mount; the per-volume contract is covered by + # `add_cursor_config_volumes adds AGENTS.md, rules, and skills`. + SETTINGS_FILE=".sandcat/settings.json" + mkdir -p "$BATS_TEST_TMPDIR/.sandcat" + touch "$BATS_TEST_TMPDIR/$SETTINGS_FILE" + + customize_compose_file "$SETTINGS_FILE" "$COMPOSE_FILE" "cursor" "vscode" "test-project" + + yq -e '.services.agent.volumes[] | select(. == "${HOME}/.cursor/AGENTS.md:/home/vscode/.cursor/AGENTS.md:ro")' "$COMPOSE_FILE" + # No claude volumes leak through when agent=cursor. + run yq '.services.agent.volumes[] | select(test("\\.claude/"))' "$COMPOSE_FILE" + assert_output "" +} + +@test "set_proxy_tui_mode keeps addon path and mitm flags" { + # Claude path: streaming flags are intentionally absent so that + # _substitute_secrets keeps body-content leak detection. set_proxy_tui_mode + # must not silently re-introduce them by rewriting the command line. + local proxy_compose="$BATS_TEST_TMPDIR/compose-proxy.yml" + cat >"$proxy_compose" <<'YAML' +services: + mitmproxy: + command: mitmweb --mode wireguard --web-host 0.0.0.0 --set web_password=mitmproxy --set http2=true -s /scripts/mitmproxy_addon_claude.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 -s /scripts/mitmproxy_addon_claude.py" + + yq -e '.services.mitmproxy.command | contains("/scripts/mitmproxy_addon_claude.py")' "$proxy_compose" + yq -e '.services.mitmproxy.command | contains("stream_large_bodies") | not' "$proxy_compose" + yq -e '.services.mitmproxy | has("ports") | not' "$proxy_compose" +} + +@test "set_proxy_tui_mode keeps cursor addon path" { + local proxy_compose="$BATS_TEST_TMPDIR/compose-proxy-cursor.yml" + 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 + 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" + yq -e '.services.mitmproxy.command | contains("/scripts/mitmproxy_addon_cursor.py")' "$proxy_compose" +} diff --git a/cli/test/init/extensions.bats b/cli/test/init/extensions.bats index 3a7e858..6dbc376 100644 --- a/cli/test/init/extensions.bats +++ b/cli/test/init/extensions.bats @@ -8,6 +8,11 @@ setup() { DEVCONTAINER_JSON="$BATS_TEST_TMPDIR/devcontainer.json" cp "$SCT_TEMPLATEDIR/devcontainer/devcontainer.json" "$DEVCONTAINER_JSON" + mkdir -p "$BATS_TEST_TMPDIR/sandcat/scripts" + cp "$SCT_TEMPLATEDIR/devcontainer/sandcat/compose-proxy.yml" "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + touch "$BATS_TEST_TMPDIR/compose-all.yml" + touch "$BATS_TEST_TMPDIR/Dockerfile.app" + touch "$BATS_TEST_TMPDIR/sandcat/scripts/app-user-init.sh" } teardown() { @@ -35,6 +40,14 @@ teardown() { } @test "customize_devcontainer_extensions preserves existing extensions" { + { + echo 'include: []' + echo 'services: {agent: {environment: []}}' + } > "$BATS_TEST_TMPDIR/compose-all.yml" + echo "__AGENT_DOCKER_INSTALL__" > "$BATS_TEST_TMPDIR/Dockerfile.app" + echo "__AGENT_USER_INIT__" > "$BATS_TEST_TMPDIR/sandcat/scripts/app-user-init.sh" + customize_agent_templates "$BATS_TEST_TMPDIR" "claude" + customize_devcontainer_extensions "$DEVCONTAINER_JSON" python run grep '"anthropic.claude-code"' "$DEVCONTAINER_JSON" @@ -44,21 +57,32 @@ teardown() { assert_success } -@test "customize_devcontainer_extensions removes placeholder with no extensions" { +@test "customize_devcontainer_extensions removes __STACK_EXTENSIONS__ placeholder" { + # The placeholder line must always be consumed, regardless of whether + # the selected stacks contribute any extension. Bats has no parametric + # tests, so we cover both branches in a single test: + # - "node" — no extension contribution + # - "python" — contributes an extension customize_devcontainer_extensions "$DEVCONTAINER_JSON" node - run grep "__STACK_EXTENSIONS__" "$DEVCONTAINER_JSON" assert_failure -} -@test "customize_devcontainer_extensions removes placeholder when extensions added" { + # Re-run with an extension-contributing stack on a fresh fixture. + cp "$SCT_TEMPLATEDIR/devcontainer/devcontainer.json" "$DEVCONTAINER_JSON" customize_devcontainer_extensions "$DEVCONTAINER_JSON" python - run grep "__STACK_EXTENSIONS__" "$DEVCONTAINER_JSON" assert_failure } @test "customize_devcontainer_extensions is a no-op for empty stacks" { + { + echo 'include: []' + echo 'services: {agent: {environment: []}}' + } > "$BATS_TEST_TMPDIR/compose-all.yml" + echo "__AGENT_DOCKER_INSTALL__" > "$BATS_TEST_TMPDIR/Dockerfile.app" + echo "__AGENT_USER_INIT__" > "$BATS_TEST_TMPDIR/sandcat/scripts/app-user-init.sh" + customize_agent_templates "$BATS_TEST_TMPDIR" "claude" + customize_devcontainer_extensions "$DEVCONTAINER_JSON" run grep "__STACK_EXTENSIONS__" "$DEVCONTAINER_JSON" @@ -67,3 +91,88 @@ teardown() { run grep '"anthropic.claude-code"' "$DEVCONTAINER_JSON" assert_success } + +@test "customize_agent_templates sets cursor extension baseline" { + { + echo 'include: []' + echo 'services: {agent: {environment: []}}' + } > "$BATS_TEST_TMPDIR/compose-all.yml" + echo "__AGENT_DOCKER_INSTALL__" > "$BATS_TEST_TMPDIR/Dockerfile.app" + echo "__AGENT_USER_INIT__" > "$BATS_TEST_TMPDIR/sandcat/scripts/app-user-init.sh" + + customize_agent_templates "$BATS_TEST_TMPDIR" "cursor" + + run grep '"anysphere.cursor"' "$DEVCONTAINER_JSON" + assert_success +} + +@test "customize_agent_templates sets claude mitmproxy defaults" { + { + echo 'include: []' + echo 'services: {agent: {environment: []}}' + } > "$BATS_TEST_TMPDIR/compose-all.yml" + echo "__AGENT_DOCKER_INSTALL__" > "$BATS_TEST_TMPDIR/Dockerfile.app" + echo "__AGENT_USER_INIT__" > "$BATS_TEST_TMPDIR/sandcat/scripts/app-user-init.sh" + + customize_agent_templates "$BATS_TEST_TMPDIR" "claude" + + run grep 'http2=true' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_success + + run grep '/scripts/mitmproxy_addon_claude.py' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_success + + # Streaming-related flags are Cursor-only; including them on the Claude + # path would weaken the body-content placeholder leak check in + # _substitute_secrets (mitmproxy buffers <1MB bodies by default). + run grep 'stream_large_bodies=1m' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_failure + + run grep 'connection_strategy=lazy' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_failure + + run grep 'anticomp=true' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_failure + + run grep 'timeout_read=300' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_failure + + # Placeholder must be fully resolved. + run grep '__AGENT_MITM_STREAMING_FLAGS__' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_failure +} + +@test "customize_agent_templates adds cursor bootstrap settings" { + { + echo 'include: []' + echo 'services: {agent: {environment: []}}' + } > "$BATS_TEST_TMPDIR/compose-all.yml" + echo "__AGENT_DOCKER_INSTALL__" > "$BATS_TEST_TMPDIR/Dockerfile.app" + echo "__AGENT_USER_INIT__" > "$BATS_TEST_TMPDIR/sandcat/scripts/app-user-init.sh" + + customize_agent_templates "$BATS_TEST_TMPDIR" "cursor" + + run grep '"$HOME/.config/cursor/cli-config.json"' "$BATS_TEST_TMPDIR/sandcat/scripts/app-user-init.sh" + assert_success + + run grep 'http2=true' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_success + + run grep '/scripts/mitmproxy_addon_cursor.py' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_success + + run grep 'stream_large_bodies=1m' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_success + + run grep 'connection_strategy=lazy' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_success + + run grep 'anticomp=true' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_success + + run grep 'timeout_read=300' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_success + + run grep '__AGENT_MITM_STREAMING_FLAGS__' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml" + assert_failure +} diff --git a/cli/test/init/init.bats b/cli/test/init/init.bats index f4d41fa..2f2288f 100644 --- a/cli/test/init/init.bats +++ b/cli/test/init/init.bats @@ -14,6 +14,11 @@ setup() { mkdir -p "$SCT_HOME_DIR" sct_home() { echo "$SCT_HOME_DIR"; } export -f sct_home + + # Isolate $HOME so ensure_host_agent_config_paths doesn't poke the real + # user's ~/.claude / ~/.cursor while running the test suite. + export HOME="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME" } teardown() { @@ -39,7 +44,17 @@ teardown() { stub devcontainer \ "--settings-file .sandcat/settings.json --project-path * --agent claude --ide jetbrains --name test --stacks * --proxy web : :" - run init --agent claude --ide jetbrains --name test --path "$PROJECT_DIR" --stacks "" --proxy web + run init --agent claude --ide jetbrains --name test --path "$PROJECT_DIR" --stacks "" --proxy web --features "" + assert_success +} + +@test "init accepts cursor as valid --agent value" { + stub settings \ + "$PROJECT_DIR/.sandcat/settings.json cursor vscode : :" + stub devcontainer \ + "--settings-file .sandcat/settings.json --project-path * --agent cursor --ide vscode --name test --stacks * --proxy web : :" + + run init --agent cursor --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --proxy web --features "" assert_success } @@ -48,12 +63,12 @@ teardown() { stub devcontainer \ "--settings-file .sandcat/settings.json --project-path $PROJECT_DIR --agent claude --ide vscode --name test --stacks 'python rust' --proxy web : :" - run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "python,rust" --proxy web + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "python,rust" --proxy web --features "" assert_success } @test "init rejects invalid --stacks value" { - run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "python,invalid" + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "python,invalid" --features "" assert_failure assert_output --partial "Invalid stack: invalid" } @@ -63,7 +78,7 @@ teardown() { stub devcontainer \ "--settings-file .sandcat/settings.json --project-path $PROJECT_DIR --agent claude --ide vscode --name test --stacks 'java scala' --proxy web : :" - run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "scala" --proxy web + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "scala" --proxy web --features "" assert_success } @@ -78,11 +93,11 @@ teardown() { stub read_line "* : echo ''" stub select_option \ - "'Select agent:' claude : echo claude" \ + "'Select agent:' claude cursor : echo claude" \ "'Select IDE:' vscode jetbrains none : echo vscode" stub select_multiple \ "'Select optional features (comma-separated numbers, enter for defaults):' 'tui (mitmproxy console instead of web UI)' 1password -- 1password : echo 1password" \ - "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet : echo ''" + "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet zig : echo ''" stub add_op_token_to_user_settings ":" local expected_name @@ -98,6 +113,47 @@ teardown() { assert_success } +@test "init pre-creates host paths for claude config mount" { + 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 : :" + + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --proxy web --features "" + assert_success + + # Directories pre-created so Docker won't materialise them as root-owned + [[ -d "$HOME/.claude/agents" ]] + [[ -d "$HOME/.claude/commands" ]] + [[ -f "$HOME/.claude/CLAUDE.md" ]] +} + +@test "init pre-creates host paths for cursor config mount" { + stub settings "$PROJECT_DIR/.sandcat/settings.json cursor vscode : :" + stub devcontainer \ + "--settings-file .sandcat/settings.json --project-path * --agent cursor --ide vscode --name test --stacks * --proxy web : :" + + run init --agent cursor --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --proxy web --features "" + assert_success + + [[ -d "$HOME/.cursor/rules" ]] + [[ -d "$HOME/.cursor/skills" ]] + [[ -f "$HOME/.cursor/AGENTS.md" ]] +} + +@test "init skips host pre-creation when SANDCAT_MOUNT_CURSOR_CONFIG=false" { + export SANDCAT_MOUNT_CURSOR_CONFIG=false + + stub settings "$PROJECT_DIR/.sandcat/settings.json cursor vscode : :" + stub devcontainer \ + "--settings-file .sandcat/settings.json --project-path * --agent cursor --ide vscode --name test --stacks * --proxy web : :" + + run init --agent cursor --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --proxy web --features "" + assert_success + + [[ ! -d "$HOME/.cursor/rules" ]] + [[ ! -e "$HOME/.cursor/AGENTS.md" ]] +} + @test "init interactive flow (devcontainer mode)" { unset -f read_line unset -f select_option @@ -105,11 +161,11 @@ teardown() { stub read_line "* : echo ''" stub select_option \ - "'Select agent:' claude : echo claude" \ + "'Select agent:' claude cursor : echo claude" \ "'Select IDE:' vscode jetbrains none : echo vscode" stub select_multiple \ "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 1password -- : echo ''" \ - "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet : echo ''" + "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet zig : echo ''" local expected_name expected_name=$(basename "$PROJECT_DIR")-sandbox diff --git a/cli/test/init/regression.bats b/cli/test/init/regression.bats index def0577..7479f9f 100644 --- a/cli/test/init/regression.bats +++ b/cli/test/init/regression.bats @@ -45,6 +45,14 @@ assert_claude_environment_vars() { yq -e '.services.agent.environment.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC == 1' "$compose_file" } +assert_cursor_environment_vars() { + local compose_file=$1 + + # Cursor template must omit `environment:` entirely when there are no + # compose-level env vars. `environment: {}` breaks compose validation. + yq -e '.services.agent | has("environment") | not' "$compose_file" +} + assert_common_volumes() { local compose_file=$1 @@ -126,7 +134,8 @@ assert_claude_volumes() { " "$compose_file" } -assert_customization_volumes() { +# Mitmproxy project settings mount + optional .git — used for all IDE/agent combos when enabled. +assert_customization_volumes_core() { local compose_file=$1 # Bind: settings directory (read-only) @@ -150,8 +159,12 @@ assert_customization_volumes() { .read_only == true ) " "$compose_file" +} + +# Active .idea bind mount — only when JetBrains IDE (or explicit SANDCAT_MOUNT_IDEA_READONLY=true). +assert_customization_volumes_idea() { + local compose_file=$1 - # Bind: .idea (read-only) PROJECT_DIR="$PROJECT_DIR" yq -e " .services.agent.volumes[] | select( @@ -161,7 +174,37 @@ assert_customization_volumes() { .read_only == true ) " "$compose_file" +} + +assert_customization_volumes() { + local compose_file=$1 + + assert_customization_volumes_core "$compose_file" + assert_customization_volumes_idea "$compose_file" +} + +assert_cursor_volumes() { + local compose_file=$1 + + HOME="$HOME" yq -e " + .services.agent.volumes[] | + select( + .type == \"bind\" and + .source == (env(HOME) + \"/.cursor/AGENTS.md\") and + .target == \"/home/vscode/.cursor/AGENTS.md\" and + .read_only == true + ) + " "$compose_file" + HOME="$HOME" yq -e " + .services.agent.volumes[] | + select( + .type == \"bind\" and + .source == (env(HOME) + \"/.cursor/rules\") and + .target == \"/home/vscode/.cursor/rules\" and + .read_only == true + ) + " "$compose_file" } assert_devcontainer_volume() { @@ -200,6 +243,20 @@ claude_agent_compose_file_has_expected_content() { assert_customization_volumes "$compose_file" } +cursor_agent_compose_file_has_expected_content() { + local compose_file=$1 + + assert_proxy_service "$compose_file" + assert_agent_service "$compose_file" + assert_cursor_environment_vars "$compose_file" + assert_common_volumes "$compose_file" + + assert_named_volumes "$compose_file" "agent-home" "mitmproxy-config" + assert_cursor_volumes "$compose_file" + # Cursor regression uses --ide vscode and SANDCAT_MOUNT_IDEA_READONLY=false — no active .idea mount. + assert_customization_volumes_core "$compose_file" +} + @test "devcontainer end-to-end: creates devcontainer config for claude agent" { export SANDCAT_MOUNT_CLAUDE_CONFIG="true" export SANDCAT_ENABLE_DOTFILES="true" @@ -225,3 +282,26 @@ claude_agent_compose_file_has_expected_content() { assert_devcontainer_volume "$effective_file" assert_jetbrains_capabilities "$effective_file" } + +@test "devcontainer end-to-end: creates devcontainer config for cursor agent" { + export SANDCAT_MOUNT_CURSOR_CONFIG="true" + export SANDCAT_ENABLE_DOTFILES="true" + export SANDCAT_MOUNT_GIT_READONLY="true" + export SANDCAT_MOUNT_IDEA_READONLY="false" + + run devcontainer \ + --settings-file "$SETTINGS_FILE" \ + --project-path "$PROJECT_DIR" \ + --agent "cursor" \ + --ide "vscode" + assert_success + assert_output --partial "Devcontainer dir created at .devcontainer" + + local effective_file="$BATS_TEST_TMPDIR/effective-compose-cursor.yml" + docker compose -f "$PROJECT_DIR/.devcontainer/compose-all.yml" config > "$effective_file" + + yq -e '.name == "project-sandbox"' "$effective_file" + + cursor_agent_compose_file_has_expected_content "$effective_file" + assert_devcontainer_volume "$effective_file" +} diff --git a/cli/test/init/stacks.bats b/cli/test/init/stacks.bats index 02247e9..b4d6cf5 100644 --- a/cli/test/init/stacks.bats +++ b/cli/test/init/stacks.bats @@ -58,6 +58,9 @@ teardown() { run stack_extension dotnet assert_output "ms-dotnettools.csdevkit" + + run stack_extension zig + assert_output "ziglang.vscode-zig" } @test "stack_extension returns empty for node" { diff --git a/cli/test/init/user_settings.bats b/cli/test/init/user_settings.bats index ed4dcce..f7ff910 100644 --- a/cli/test/init/user_settings.bats +++ b/cli/test/init/user_settings.bats @@ -78,6 +78,29 @@ teardown() { assert_output "github.com" } +@test "create_user_settings includes CURSOR_API_KEY secret" { + stub git \ + "config --global user.name : echo ''" \ + "config --global user.email : echo ''" + + create_user_settings cursor + + local settings="$HOME/.config/sandcat/settings.json" + run yq -r '.secrets.CURSOR_API_KEY.hosts[0]' "$settings" + assert_output "api.cursor.sh" +} + +@test "create_user_settings for cursor does not include ANTHROPIC_API_KEY" { + stub git \ + "config --global user.name : echo ''" \ + "config --global user.email : echo ''" + + create_user_settings cursor + + local settings="$HOME/.config/sandcat/settings.json" + yq -e '.secrets | has("ANTHROPIC_API_KEY") | not' "$settings" +} + @test "create_user_settings includes network rules" { stub git \ "config --global user.name : echo ''" \ @@ -91,6 +114,18 @@ teardown() { yq -e '.network[] | select(.host == "*.claude.com")' "$settings" } +@test "create_user_settings for cursor includes cursor network rules" { + stub git \ + "config --global user.name : echo ''" \ + "config --global user.email : echo ''" + + create_user_settings cursor + + local settings="$HOME/.config/sandcat/settings.json" + yq -e '.network[] | select(.host == "*.cursor.sh")' "$settings" + yq -e '.network[] | select(.host == "*.cursor.com")' "$settings" +} + @test "create_user_settings skips when file already exists" { mkdir -p "$HOME/.config/sandcat" echo '{"existing": true}' > "$HOME/.config/sandcat/settings.json" @@ -100,3 +135,30 @@ teardown() { run yq '.existing' "$HOME/.config/sandcat/settings.json" assert_output "true" } + +@test "ensure_cursor_user_settings_defaults backfills missing cursor hosts" { + mkdir -p "$HOME/.config/sandcat" + cat > "$HOME/.config/sandcat/settings.json" <<'EOF' +{ + "env": { + "GIT_USER_NAME": "Test" + }, + "secrets": { + "CURSOR_API_KEY": { + "value": "existing-key", + "hosts": ["api.cursor.sh"] + } + } +} +EOF + + ensure_cursor_user_settings_defaults + + local settings="$HOME/.config/sandcat/settings.json" + run yq -r '.secrets.CURSOR_API_KEY.value' "$settings" + assert_output "existing-key" + yq -e '.secrets.CURSOR_API_KEY.hosts[] | select(. == "api.cursor.sh")' "$settings" + yq -e '.secrets.CURSOR_API_KEY.hosts[] | select(. == "api2.cursor.sh")' "$settings" + yq -e '.secrets.CURSOR_API_KEY.hosts[] | select(. == "*.cursor.sh")' "$settings" + yq -e '.secrets.CURSOR_API_KEY.hosts[] | select(. == "*.cursor.com")' "$settings" +} diff --git a/cli/test/mitmproxy/test_mitmproxy_addon.py b/cli/test/mitmproxy/test_mitmproxy_addon.py index 0ac4f62..097899b 100644 --- a/cli/test/mitmproxy/test_mitmproxy_addon.py +++ b/cli/test/mitmproxy/test_mitmproxy_addon.py @@ -1,19 +1,31 @@ -"""Unit tests for mitmproxy-addon.py — no mitmproxy daemon needed.""" +"""Unit tests for the sandcat mitmproxy addons — no mitmproxy daemon needed. +This file covers: + + - The shared :mod:`mitmproxy_addon_common` library (network rules, settings + merging, shell escaping, op:// resolution, env file generation). + - Both agent variants — :mod:`mitmproxy_addon_claude` and + :mod:`mitmproxy_addon_cursor` — by parameterising shared behaviour over + the two ``SandcatAddon`` classes. + - Cursor-specific behaviour: Cursor Connect streaming, ``Authorization`` + header normalisation, Basic Auth substitution, secret value normalisation. +""" + +import importlib import json import os import sys import types from pathlib import Path +from urllib.parse import urlsplit from unittest.mock import MagicMock, patch -# Allow importing mitmproxy_addon from the templates directory. -_SCRIPTS_DIR = str(Path(__file__).resolve().parents[2] / "templates" / "devcontainer" / "sandcat" / "scripts") -sys.path.insert(0, _SCRIPTS_DIR) - import pytest -# Stub out mitmproxy imports so tests run without installing mitmproxy +# --------------------------------------------------------------------------- +# mitmproxy stubs — install BEFORE importing the addon modules. +# --------------------------------------------------------------------------- + _ctx = MagicMock() _http = types.ModuleType("mitmproxy.http") _dns = types.ModuleType("mitmproxy.dns") @@ -59,8 +71,13 @@ def __init__(self, method="GET", host="example.com", url="https://example.com/", self.method = method self.pretty_host = host self.url = url + split = urlsplit(url) + self.path = split.path or "/" + if split.query: + self.path += f"?{split.query}" self.headers = _Headers(headers or {}) self.content = content + self.stream = False class _Response: @@ -80,10 +97,36 @@ def make(status, body, headers): sys.modules["mitmproxy"].http = _http sys.modules["mitmproxy"].dns = _dns -# Import after mitmproxy stubs are installed in sys.modules above. -from mitmproxy_addon import SandcatAddon, SETTINGS_PATHS # noqa: E402 +# Allow importing the addon modules from the templates directory. +_SCRIPTS_DIR = str( + Path(__file__).resolve().parents[2] / "templates" / "devcontainer" / "sandcat" / "scripts" +) +sys.path.insert(0, _SCRIPTS_DIR) + +# Import after stubs are in place. +common = importlib.import_module("mitmproxy_addon_common") +claude_mod = importlib.import_module("mitmproxy_addon_claude") +cursor_mod = importlib.import_module("mitmproxy_addon_cursor") + +ClaudeAddon = claude_mod.SandcatAddon +CursorAddon = cursor_mod.SandcatAddon +BaseAddon = common.SandcatAddon + +# Patch targets all live in the shared library — both agent variants inherit +# their settings/secret/network code from the base class defined there. +_COMMON = "mitmproxy_addon_common" + +# Parameter set for tests that should pass for both agent variants. +ADDONS = [ + pytest.param(ClaudeAddon, id="claude"), + pytest.param(CursorAddon, id="cursor"), +] +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + def _make_flow(method="GET", host="example.com", url=None, headers=None, content=None): flow = MagicMock() flow.request = _Request( @@ -108,41 +151,42 @@ def _make_dns_flow(name="example.com"): # --------------------------------------------------------------------------- -# Network rules +# Network rules — shared logic, parameterised over both variants. # --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestNetworkRules: - def test_first_match_wins_allow_before_deny(self): - addon = SandcatAddon() + def test_first_match_wins_allow_before_deny(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*"}, {"action": "deny", "host": "*"}, ] assert addon._is_request_allowed("GET", "example.com") is True - def test_first_match_wins_deny_before_allow(self): - addon = SandcatAddon() + def test_first_match_wins_deny_before_allow(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "deny", "host": "*"}, {"action": "allow", "host": "*"}, ] assert addon._is_request_allowed("GET", "example.com") is False - def test_default_deny_on_no_match(self): - addon = SandcatAddon() + def test_default_deny_on_no_match(self, addon_cls): + addon = addon_cls() addon.network_rules = [] assert addon._is_request_allowed("GET", "example.com") is False - def test_method_specific_rule(self): - addon = SandcatAddon() + def test_method_specific_rule(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*", "method": "GET"}, ] assert addon._is_request_allowed("GET", "example.com") is True assert addon._is_request_allowed("POST", "example.com") is False - def test_method_omitted_matches_any(self): - addon = SandcatAddon() + def test_method_omitted_matches_any(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*"}, ] @@ -150,16 +194,16 @@ def test_method_omitted_matches_any(self): assert addon._is_request_allowed("POST", "example.com") is True assert addon._is_request_allowed("PUT", "example.com") is True - def test_host_glob_pattern(self): - addon = SandcatAddon() + def test_host_glob_pattern(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*.github.com"}, ] assert addon._is_request_allowed("GET", "api.github.com") is True assert addon._is_request_allowed("GET", "example.com") is False - def test_full_ruleset_from_plan(self): - addon = SandcatAddon() + def test_full_ruleset_from_plan(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*", "method": "GET"}, {"action": "allow", "host": "*.github.com", "method": "POST"}, @@ -175,22 +219,22 @@ def test_full_ruleset_from_plan(self): # PUT anything → allowed (rule 4) assert addon._is_request_allowed("PUT", "example.com") is True - def test_method_matching_is_case_insensitive(self): - addon = SandcatAddon() + def test_method_matching_is_case_insensitive(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*", "method": "get"}, ] assert addon._is_request_allowed("GET", "example.com") is True - def test_none_method_bypasses_method_check(self): - addon = SandcatAddon() + def test_none_method_bypasses_method_check(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*", "method": "GET"}, ] assert addon._is_request_allowed(None, "example.com") is True - def test_host_matching_is_case_insensitive(self): - addon = SandcatAddon() + def test_host_matching_is_case_insensitive(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "deny", "host": "evil.com"}, {"action": "allow", "host": "*"}, @@ -198,15 +242,15 @@ def test_host_matching_is_case_insensitive(self): assert addon._is_request_allowed("GET", "Evil.COM") is False assert addon._is_request_allowed("GET", "EVIL.COM") is False - def test_rule_host_pattern_case_insensitive(self): - addon = SandcatAddon() + def test_rule_host_pattern_case_insensitive(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*.GitHub.COM"}, ] assert addon._is_request_allowed("GET", "api.github.com") is True - def test_dns_trailing_dot_stripped(self): - addon = SandcatAddon() + def test_dns_trailing_dot_stripped(self, addon_cls): + addon = addon_cls() addon.network_rules = [ {"action": "allow", "host": "*.github.com"}, ] @@ -215,12 +259,14 @@ def test_dns_trailing_dot_stripped(self): # --------------------------------------------------------------------------- -# Secret substitution +# Secret substitution — shared logic, parameterised over both variants. # --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestSecretSubstitution: - def _make_addon_with_secrets(self): - addon = SandcatAddon() + @staticmethod + def _make_addon(addon_cls): + addon = addon_cls() addon.network_rules = [{"action": "allow", "host": "*"}] addon.secrets = { "API_KEY": { @@ -231,8 +277,8 @@ def _make_addon_with_secrets(self): } return addon - def test_placeholder_replaced_in_header(self): - addon = self._make_addon_with_secrets() + def test_placeholder_replaced_in_header(self, addon_cls): + addon = self._make_addon(addon_cls) flow = _make_flow( host="api.example.com", headers={"Authorization": "Bearer SANDCAT_PLACEHOLDER_API_KEY"}, @@ -241,19 +287,22 @@ def test_placeholder_replaced_in_header(self): assert flow.response is None assert flow.request.headers["Authorization"] == "Bearer real-secret-value" - def test_placeholder_replaced_in_body(self): - addon = self._make_addon_with_secrets() + def test_placeholder_replaced_in_body(self, addon_cls): + addon = self._make_addon(addon_cls) + # Cursor gates body substitution on a textual content-type; provide one + # so the assertion holds for both variants. flow = _make_flow( method="POST", host="api.example.com", + headers={"content-type": "application/json"}, content=b'{"key": "SANDCAT_PLACEHOLDER_API_KEY"}', ) addon.request(flow) assert flow.response is None assert b"real-secret-value" in flow.request.content - def test_placeholder_replaced_in_url(self): - addon = self._make_addon_with_secrets() + def test_placeholder_replaced_in_url(self, addon_cls): + addon = self._make_addon(addon_cls) flow = _make_flow( host="api.example.com", url="https://api.example.com/?token=SANDCAT_PLACEHOLDER_API_KEY", @@ -262,15 +311,15 @@ def test_placeholder_replaced_in_url(self): assert flow.response is None assert "real-secret-value" in flow.request.url - def test_no_op_when_placeholder_absent(self): - addon = self._make_addon_with_secrets() + def test_no_op_when_placeholder_absent(self, addon_cls): + addon = self._make_addon(addon_cls) flow = _make_flow(host="api.example.com") addon.request(flow) assert flow.response is None assert "real-secret-value" not in flow.request.url - def test_leak_detection_blocks_disallowed_host(self): - addon = self._make_addon_with_secrets() + def test_leak_detection_blocks_disallowed_host(self, addon_cls): + addon = self._make_addon(addon_cls) flow = _make_flow( host="evil.com", headers={"Authorization": "Bearer SANDCAT_PLACEHOLDER_API_KEY"}, @@ -281,12 +330,422 @@ def test_leak_detection_blocks_disallowed_host(self): # --------------------------------------------------------------------------- -# Integration +# Body substitution behaviour that differs between variants: +# - Claude: body substitution is unconditional (no content-type check). +# - Cursor: body substitution requires a textual content-type. +# --------------------------------------------------------------------------- + +class TestBodySubstitutionContentTypeGate: + @staticmethod + def _make_addon(addon_cls): + addon = addon_cls() + addon.network_rules = [{"action": "allow", "host": "*"}] + addon.secrets = { + "API_KEY": { + "value": "real-secret", + "hosts": ["api.example.com"], + "placeholder": "SANDCAT_PLACEHOLDER_API_KEY", + } + } + return addon + + def test_claude_substitutes_body_without_content_type(self): + addon = self._make_addon(ClaudeAddon) + flow = _make_flow( + method="POST", + host="api.example.com", + content=b"SANDCAT_PLACEHOLDER_API_KEY", + ) + addon.request(flow) + assert flow.request.content == b"real-secret" + + def test_cursor_skips_body_without_textual_content_type(self): + addon = self._make_addon(CursorAddon) + flow = _make_flow( + method="POST", + host="api.example.com", + content=b"SANDCAT_PLACEHOLDER_API_KEY", + ) + addon.request(flow) + assert flow.request.content == b"SANDCAT_PLACEHOLDER_API_KEY" + + def test_cursor_substitutes_body_for_json(self): + addon = self._make_addon(CursorAddon) + flow = _make_flow( + method="POST", + host="api.example.com", + headers={"content-type": "application/json"}, + content=b"SANDCAT_PLACEHOLDER_API_KEY", + ) + addon.request(flow) + assert flow.request.content == b"real-secret" + + +# --------------------------------------------------------------------------- +# Cursor-specific: streaming traffic stays opaque. +# --------------------------------------------------------------------------- + +class TestCursorStreaming: + @staticmethod + def _make_addon(): + addon = CursorAddon() + addon.network_rules = [{"action": "allow", "host": "*"}] + addon.secrets = { + "CURSOR_API_KEY": { + "value": "cursor-secret", + "hosts": ["*.cursor.sh", "*.cursor.com"], + "placeholder": "SANDCAT_PLACEHOLDER_CURSOR_API_KEY", + } + } + return addon + + def test_streaming_sets_stream_and_identity_encoding(self): + addon = self._make_addon() + flow = _make_flow( + method="POST", + host="api2.cursor.sh", + url="https://api2.cursor.sh/agent.v1.AgentService/RunSSE", + headers={"content-type": "application/connect+proto"}, + content=b"\x00\x00\x00\x00&", + ) + addon.request(flow) + assert flow.response is None + assert flow.request.stream is True + assert flow.request.headers["accept-encoding"] == "identity" + # The Cursor template never injects CURSOR-API-KEY; placeholders go in Authorization. + assert "CURSOR-API-KEY" not in flow.request.headers + assert flow.request.content == b"\x00\x00\x00\x00&" + + def test_streaming_does_not_touch_body_placeholders(self): + addon = self._make_addon() + flow = _make_flow( + method="POST", + host="api2.cursor.sh", + url="https://api2.cursor.sh/agent.v1.AgentService/RunSSE", + headers={"content-type": "application/connect+proto"}, + content=b"SANDCAT_PLACEHOLDER_CURSOR_API_KEY", + ) + addon.request(flow) + assert flow.response is None + assert flow.request.content == b"SANDCAT_PLACEHOLDER_CURSOR_API_KEY" + + def test_streaming_substitutes_bearer_placeholder(self): + addon = self._make_addon() + flow = _make_flow( + method="POST", + host="api2.cursor.sh", + url="https://api2.cursor.sh/agent.v1.AgentService/RunSSE", + headers={ + "content-type": "application/connect+proto", + "authorization": "Bearer SANDCAT_PLACEHOLDER_CURSOR_API_KEY", + }, + ) + addon.request(flow) + assert flow.response is None + assert flow.request.headers["authorization"] == "Bearer cursor-secret" + + def test_streaming_response_is_marked_streaming(self): + addon = self._make_addon() + flow = _make_flow( + method="POST", + host="api2.cursor.sh", + url="https://api2.cursor.sh/agent.v1.AgentService/RunSSE", + headers={"content-type": "application/connect+proto"}, + ) + flow.response = types.SimpleNamespace(stream=False) + addon.responseheaders(flow) + assert flow.response.stream is True + + def test_repository_service_path_is_streaming(self): + addon = self._make_addon() + flow = _make_flow( + method="POST", + host="api3.cursor.sh", + url="https://api3.cursor.sh/aiserver.v1.RepositoryService/Foo", + ) + assert addon._is_streaming_request(flow) is True + + def test_non_cursor_host_not_streaming(self): + # Even if the path looks Cursor-like, a non-Cursor host should never + # be classified as streaming. + addon = self._make_addon() + flow = _make_flow( + method="POST", + host="api.openai.com", + url="https://api.openai.com/agent.v1.AgentService/Run", + ) + assert addon._is_streaming_request(flow) is False + + def test_cursor_host_without_streaming_indicator_not_streaming(self): + addon = self._make_addon() + flow = _make_flow( + method="GET", + host="cursor.com", + url="https://cursor.com/about", + ) + assert addon._is_streaming_request(flow) is False + + def test_content_type_alone_does_not_trigger_streaming(self): + # Regression: a client-supplied application/connect+proto content-type + # must NOT flip on streaming when the path is unrelated. Otherwise + # any request with the right header could bypass body substitution + # and the content-based placeholder leak check. + addon = self._make_addon() + flow = _make_flow( + method="POST", + host="api.cursor.sh", + url="https://api.cursor.sh/some/other/endpoint", + headers={"content-type": "application/connect+proto"}, + ) + assert addon._is_streaming_request(flow) is False + + def test_claude_responseheaders_is_noop(self): + """Base behaviour: never touches the response stream flag.""" + addon = ClaudeAddon() + flow = _make_flow(host="example.com") + flow.response = types.SimpleNamespace(stream=False) + addon.responseheaders(flow) + assert flow.response.stream is False + + def test_cursor_responseheaders_noop_for_non_streaming(self): + """Non-streaming Cursor requests must not be marked stream=True. + + Otherwise mitmproxy would skip body buffering on regular JSON + endpoints, defeating the body-content placeholder leak check. + """ + addon = self._make_addon() + flow = _make_flow( + method="GET", + host="cursor.com", + url="https://cursor.com/about", + ) + flow.response = types.SimpleNamespace(stream=False) + addon.responseheaders(flow) + assert flow.response.stream is False + + def test_streaming_to_disallowed_host_blocks_authorization_leak(self): + """Even on a streaming path, leak detection in `_substitute_secrets` + must run on URL/header/Basic-Auth surfaces. + + The network allowlist may permit a host via wildcard, but the + secret's per-key allowlist (`hosts`) is narrower. A streaming POST + whose Authorization header carries the placeholder, addressed to a + host that is *not* in the secret's hosts, must yield a 403. + """ + addon = CursorAddon() + addon.network_rules = [{"action": "allow", "host": "*"}] + # Secret only allowed for api-allowed.cursor.sh, but the request + # below goes to api2.cursor.sh. + addon.secrets = { + "CURSOR_API_KEY": { + "value": "cursor-secret", + "hosts": ["api-allowed.cursor.sh"], + "placeholder": "SANDCAT_PLACEHOLDER_CURSOR_API_KEY", + } + } + + flow = _make_flow( + method="POST", + host="api2.cursor.sh", + url="https://api2.cursor.sh/agent.v1.AgentService/RunSSE", + headers={ + "content-type": "application/connect+proto", + "authorization": "Bearer SANDCAT_PLACEHOLDER_CURSOR_API_KEY", + }, + content=b"\x00\x00\x00\x00&", + ) + # Sanity check: this IS a streaming path so the body-side substitution + # would skip — the leak detection has to fire from the auth header. + assert addon._is_streaming_request(flow) is True + + addon.request(flow) + + assert flow.response is not None + # Stub ``http.Response.make`` returns ``{"status", "body", "headers"}``. + assert flow.response["status"] == 403 + assert b"CURSOR_API_KEY" in flow.response["body"] + # The original placeholder must NOT have been substituted. + assert flow.request.headers["authorization"] == ( + "Bearer SANDCAT_PLACEHOLDER_CURSOR_API_KEY" + ) + + +# --------------------------------------------------------------------------- +# Cursor-specific: Basic Auth substitution. +# --------------------------------------------------------------------------- + +class TestCursorBasicAuth: + @staticmethod + def _make_addon(): + addon = CursorAddon() + addon.network_rules = [{"action": "allow", "host": "*"}] + addon.secrets = { + "API_KEY": { + "value": "real-pass", + "hosts": ["api.example.com"], + "placeholder": "SANDCAT_PLACEHOLDER_API_KEY", + } + } + return addon + + def test_basic_auth_placeholder_substituted(self): + import base64 + + encoded = base64.b64encode(b"user:SANDCAT_PLACEHOLDER_API_KEY").decode("ascii") + addon = self._make_addon() + flow = _make_flow( + host="api.example.com", + headers={"authorization": f"Basic {encoded}"}, + ) + addon.request(flow) + assert flow.response is None + assert flow.request.headers["authorization"].startswith("Basic ") + new_encoded = flow.request.headers["authorization"].split(" ", 1)[1] + assert base64.b64decode(new_encoded) == b"user:real-pass" + + def test_basic_auth_no_placeholder_untouched(self): + import base64 + + encoded = base64.b64encode(b"user:plain-pass").decode("ascii") + addon = self._make_addon() + flow = _make_flow( + host="api.example.com", + headers={"authorization": f"Basic {encoded}"}, + ) + addon.request(flow) + assert flow.request.headers["authorization"] == f"Basic {encoded}" + + def test_basic_auth_invalid_base64_returns_unchanged(self): + # Direct unit test of the helper: invalid base64 should not raise. + # "AAA" is missing padding and triggers binascii.Error. + _, replaced = CursorAddon._replace_placeholder_in_basic_auth( + "Basic AAA", "X", "Y" + ) + assert replaced is False + + def test_basic_auth_empty_payload_returns_unchanged(self): + # "Basic " with empty token short-circuits before decoding. + _, replaced = CursorAddon._replace_placeholder_in_basic_auth( + "Basic ", "X", "Y" + ) + assert replaced is False + + def test_basic_auth_non_basic_scheme_unchanged(self): + result, replaced = CursorAddon._replace_placeholder_in_basic_auth( + "Bearer abc", "P", "v" + ) + assert replaced is False + assert result == "Bearer abc" + + def test_claude_does_not_touch_basic_auth(self): + """Base addon never inspects Basic Auth payloads.""" + import base64 + + encoded = base64.b64encode(b"user:SANDCAT_PLACEHOLDER_API_KEY").decode("ascii") + addon = ClaudeAddon() + addon.network_rules = [{"action": "allow", "host": "*"}] + addon.secrets = { + "API_KEY": { + "value": "real-pass", + "hosts": ["api.example.com"], + "placeholder": "SANDCAT_PLACEHOLDER_API_KEY", + } + } + flow = _make_flow( + host="api.example.com", + headers={"authorization": f"Basic {encoded}"}, + ) + addon.request(flow) + # Claude leaves the encoded payload untouched (placeholder hidden inside base64). + assert flow.request.headers["authorization"] == f"Basic {encoded}" + + +# --------------------------------------------------------------------------- +# Cursor-specific: Authorization header normalisation after substitution. +# --------------------------------------------------------------------------- + +class TestCursorAuthorizationNormalization: + def test_bearer_token_trimmed(self): + addon = CursorAddon() + addon.network_rules = [{"action": "allow", "host": "*"}] + addon.secrets = { + "T": { + "value": " real-token ", + "hosts": ["api.example.com"], + "placeholder": "SANDCAT_PLACEHOLDER_T", + } + } + flow = _make_flow( + host="api.example.com", + headers={"authorization": "Bearer SANDCAT_PLACEHOLDER_T"}, + ) + addon.request(flow) + assert flow.request.headers["authorization"] == "Bearer real-token" + + def test_bearer_scheme_case_preserved(self): + addon = CursorAddon() + # Direct hook test + assert ( + addon._normalize_authorization_header("bearer abc ") == "bearer abc" + ) + assert ( + addon._normalize_authorization_header("BEARER abc") == "BEARER abc" + ) + + def test_non_bearer_passthrough_only_strips(self): + addon = CursorAddon() + assert addon._normalize_authorization_header("Basic abc== ") == "Basic abc==" + + def test_claude_does_not_trim_authorization(self): + addon = ClaudeAddon() + addon.network_rules = [{"action": "allow", "host": "*"}] + addon.secrets = { + "T": { + "value": " spacey ", + "hosts": ["api.example.com"], + "placeholder": "SANDCAT_PLACEHOLDER_T", + } + } + flow = _make_flow( + host="api.example.com", + headers={"authorization": "Bearer SANDCAT_PLACEHOLDER_T"}, + ) + addon.request(flow) + # Claude does no normalization — the leading/trailing spaces survive. + assert flow.request.headers["authorization"] == "Bearer spacey " + + +# --------------------------------------------------------------------------- +# Cursor-specific: secret value normalization. +# --------------------------------------------------------------------------- + +class TestCursorSecretNormalization: + def test_strips_whitespace(self): + assert CursorAddon._normalize_secret_value(" abc ") == "abc" + + def test_strips_bom(self): + assert CursorAddon._normalize_secret_value("\ufeffabc") == "abc" + + def test_handles_none(self): + assert CursorAddon._normalize_secret_value(None) == "" + + def test_handles_non_string(self): + assert CursorAddon._normalize_secret_value(123) == "123" + + def test_claude_default_is_passthrough(self): + # Base/claude does not strip — values come through unchanged. + assert ClaudeAddon._normalize_secret_value(" abc ") == " abc " + assert ClaudeAddon._normalize_secret_value(None) == "" + + +# --------------------------------------------------------------------------- +# Integration — request handler ordering. # --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestIntegration: - def test_network_deny_blocks_before_substitution(self): - addon = SandcatAddon() + def test_network_deny_blocks_before_substitution(self, addon_cls): + addon = addon_cls() addon.network_rules = [{"action": "deny", "host": "*"}] addon.secrets = { "API_KEY": { @@ -306,8 +765,8 @@ def test_network_deny_blocks_before_substitution(self): # Secret should NOT have been substituted assert flow.request.headers["Authorization"] == "Bearer SANDCAT_PLACEHOLDER_API_KEY" - def test_network_allow_plus_substitution(self): - addon = SandcatAddon() + def test_network_allow_plus_substitution(self, addon_cls): + addon = addon_cls() addon.network_rules = [{"action": "allow", "host": "*"}] addon.secrets = { "API_KEY": { @@ -326,35 +785,36 @@ def test_network_allow_plus_substitution(self): # --------------------------------------------------------------------------- -# DNS proxy +# DNS proxy — shared logic, parameterised over both variants. # --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestDNSProxy: - def test_allowed_host_passes_through(self): - addon = SandcatAddon() + def test_allowed_host_passes_through(self, addon_cls): + addon = addon_cls() addon.network_rules = [{"action": "allow", "host": "*"}] flow = _make_dns_flow("example.com") addon.dns_request(flow) assert flow.response is None - def test_denied_host_returns_refused(self): - addon = SandcatAddon() + def test_denied_host_returns_refused(self, addon_cls): + addon = addon_cls() addon.network_rules = [{"action": "deny", "host": "*"}] flow = _make_dns_flow("example.com") addon.dns_request(flow) assert flow.response is not None assert flow.response["response_code"] == 5 - def test_empty_questions_returns_refused(self): - addon = SandcatAddon() + def test_empty_questions_returns_refused(self, addon_cls): + addon = addon_cls() addon.network_rules = [{"action": "allow", "host": "*"}] flow = _make_dns_flow(name=None) addon.dns_request(flow) assert flow.response is not None assert flow.response["response_code"] == 5 - def test_dns_trailing_dot_allowed(self): - addon = SandcatAddon() + def test_dns_trailing_dot_allowed(self, addon_cls): + addon = addon_cls() addon.network_rules = [{"action": "allow", "host": "*.github.com"}] flow = _make_dns_flow("api.github.com.") addon.dns_request(flow) @@ -362,43 +822,42 @@ def test_dns_trailing_dot_allowed(self): # --------------------------------------------------------------------------- -# Config loading +# Config loading — exercises the shared load() + write paths. # --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestConfigLoading: - def test_missing_settings_file_disables_addon(self): - addon = SandcatAddon() - with patch("mitmproxy_addon.os.path.isfile", return_value=False): + def test_missing_settings_file_disables_addon(self, addon_cls): + addon = addon_cls() + with patch(f"{_COMMON}.os.path.isfile", return_value=False): addon.load(MagicMock()) assert addon.env == {} assert addon.secrets == {} assert addon.network_rules == [] - def test_missing_secrets_key_uses_empty(self, tmp_path): + def test_missing_secrets_key_uses_empty(self, addon_cls, tmp_path): settings = {"network": [{"action": "allow", "host": "*"}]} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(tmp_path / "sandcat.env")): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(tmp_path / "sandcat.env")): addon.load(MagicMock()) assert addon.secrets == {} assert len(addon.network_rules) == 1 - def test_missing_network_key_uses_empty(self, tmp_path): - settings = {"secrets": { - "KEY": {"value": "v", "hosts": ["h.com"]} - }} + def test_missing_network_key_uses_empty(self, addon_cls, tmp_path): + settings = {"secrets": {"KEY": {"value": "v", "hosts": ["h.com"]}}} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(tmp_path / "sandcat.env")): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(tmp_path / "sandcat.env")): addon.load(MagicMock()) assert len(addon.secrets) == 1 assert addon.network_rules == [] - def test_placeholders_env_written_correctly(self, tmp_path): + def test_placeholders_env_written_correctly(self, addon_cls, tmp_path): settings = {"secrets": { "A": {"value": "va", "hosts": []}, "B": {"value": "vb", "hosts": []}, @@ -406,15 +865,15 @@ def test_placeholders_env_written_correctly(self, tmp_path): p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) content = env_path.read_text() assert 'export A="SANDCAT_PLACEHOLDER_A"' in content assert 'export B="SANDCAT_PLACEHOLDER_B"' in content - def test_env_vars_written_to_placeholders_env(self, tmp_path): + def test_env_vars_written_to_placeholders_env(self, addon_cls, tmp_path): settings = { "env": {"GIT_USER_NAME": "Alice", "GIT_USER_EMAIL": "alice@example.com"}, "secrets": {"K": {"value": "v", "hosts": []}}, @@ -422,189 +881,207 @@ def test_env_vars_written_to_placeholders_env(self, tmp_path): p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) content = env_path.read_text() assert 'export GIT_USER_NAME="Alice"' in content assert 'export GIT_USER_EMAIL="alice@example.com"' in content assert 'export K="SANDCAT_PLACEHOLDER_K"' in content - def test_env_vars_partial(self, tmp_path): + def test_env_vars_partial(self, addon_cls, tmp_path): settings = {"env": {"EDITOR": "vim"}} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) content = env_path.read_text() assert 'export EDITOR="vim"' in content - def test_missing_env_section_omits_vars(self, tmp_path): + def test_missing_env_section_omits_vars(self, addon_cls, tmp_path): settings = {"secrets": {"K": {"value": "v", "hosts": []}}} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) content = env_path.read_text() assert content.startswith('export K=') # --------------------------------------------------------------------------- -# Shell escaping +# Shell escaping — applies regardless of variant. # --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestShellEscaping: - def test_double_quotes_escaped(self, tmp_path): + def test_double_quotes_escaped(self, addon_cls, tmp_path): settings = {"env": {"X": 'val"ue'}} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) content = env_path.read_text() assert 'export X="val\\"ue"' in content - def test_backslashes_escaped(self, tmp_path): + def test_backslashes_escaped(self, addon_cls, tmp_path): settings = {"env": {"X": "a\\b"}} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) content = env_path.read_text() assert 'export X="a\\\\b"' in content - def test_dollar_and_backtick_escaped(self, tmp_path): + def test_dollar_and_backtick_escaped(self, addon_cls, tmp_path): settings = {"env": {"X": "$(rm -rf /)`cmd`"}} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) content = env_path.read_text() assert 'export X="\\$(rm -rf /)\\`cmd\\`"' in content + +class TestShellEscapingStaticHelpers: + """Static helpers live in the shared library; both variants reuse them.""" + def test_newlines_escaped(self): - assert SandcatAddon._shell_escape("line1\nline2") == "line1\\nline2" + assert BaseAddon._shell_escape("line1\nline2") == "line1\\nline2" def test_plain_values_unchanged(self): - assert SandcatAddon._shell_escape("hello world") == "hello world" - assert SandcatAddon._shell_escape("sk-ant-abc123") == "sk-ant-abc123" + assert BaseAddon._shell_escape("hello world") == "hello world" + assert BaseAddon._shell_escape("sk-ant-abc123") == "sk-ant-abc123" + + def test_helpers_inherited_by_variants(self): + # Sanity: subclasses inherit the same helper from the base. + assert ClaudeAddon._shell_escape == BaseAddon._shell_escape + assert CursorAddon._shell_escape == BaseAddon._shell_escape + +# --------------------------------------------------------------------------- +# Env var name validation (shared). +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestEnvVarNameValidation: - def test_valid_names(self): + def test_valid_names(self, addon_cls): for name in ["FOO", "BAR_BAZ", "_PRIVATE", "a1b2"]: - SandcatAddon._validate_env_name(name) # should not raise + addon_cls._validate_env_name(name) # should not raise - def test_invalid_names(self): + def test_invalid_names(self, addon_cls): for name in ["1BAD", "FOO BAR", 'X"; curl evil.com #', "a-b", ""]: with pytest.raises(ValueError): - SandcatAddon._validate_env_name(name) + addon_cls._validate_env_name(name) - def test_invalid_env_name_blocks_write(self, tmp_path): + def test_invalid_env_name_blocks_write(self, addon_cls, tmp_path): settings = {"env": {'BAD NAME': "value"}} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): with pytest.raises(ValueError): addon.load(MagicMock()) - def test_invalid_secret_name_blocks_write(self, tmp_path): + def test_invalid_secret_name_blocks_write(self, addon_cls, tmp_path): settings = {"secrets": {"BAD;NAME": {"value": "v", "hosts": []}}} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): with pytest.raises(ValueError): addon.load(MagicMock()) # --------------------------------------------------------------------------- -# 1Password secret resolution +# 1Password secret resolution (shared classmethod). # --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestOpSecretResolution: - def test_op_reference_resolved_via_subprocess(self): + def test_op_reference_resolved_via_subprocess(self, addon_cls): entry = {"op": "op://vault/item/field", "hosts": ["api.example.com"]} - with patch("mitmproxy_addon.subprocess.run") as mock_run: + with patch(f"{_COMMON}.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="secret-value\n", stderr="") - value = SandcatAddon._resolve_secret_value("KEY", entry) + value = addon_cls._resolve_secret_value("KEY", entry) assert value == "secret-value" mock_run.assert_called_once_with( ["op", "read", "op://vault/item/field"], capture_output=True, text=True, timeout=30, ) - def test_value_field_still_works(self): + def test_value_field_still_works(self, addon_cls): entry = {"value": "plain-secret", "hosts": []} - value = SandcatAddon._resolve_secret_value("KEY", entry) + value = addon_cls._resolve_secret_value("KEY", entry) assert value == "plain-secret" - def test_both_value_and_op_raises(self): + def test_both_value_and_op_raises(self, addon_cls): entry = {"value": "x", "op": "op://vault/item/field", "hosts": []} with pytest.raises(ValueError, match="either 'value' or 'op'"): - SandcatAddon._resolve_secret_value("KEY", entry) + addon_cls._resolve_secret_value("KEY", entry) - def test_neither_value_nor_op_raises(self): + def test_neither_value_nor_op_raises(self, addon_cls): entry = {"hosts": ["example.com"]} with pytest.raises(ValueError, match="must specify either"): - SandcatAddon._resolve_secret_value("KEY", entry) + addon_cls._resolve_secret_value("KEY", entry) - def test_op_without_prefix_raises(self): + def test_op_without_prefix_raises(self, addon_cls): entry = {"op": "vault/item/field", "hosts": []} with pytest.raises(ValueError, match="must start with 'op://'"): - SandcatAddon._resolve_secret_value("KEY", entry) + addon_cls._resolve_secret_value("KEY", entry) - def test_op_cli_not_found_raises(self): + def test_op_cli_not_found_raises(self, addon_cls): entry = {"op": "op://vault/item/field", "hosts": []} - with patch("mitmproxy_addon.subprocess.run", side_effect=FileNotFoundError): + with patch(f"{_COMMON}.subprocess.run", side_effect=FileNotFoundError): with pytest.raises(RuntimeError, match="'op' CLI not found"): - SandcatAddon._resolve_secret_value("KEY", entry) + addon_cls._resolve_secret_value("KEY", entry) - def test_op_cli_failure_raises(self): + def test_op_cli_failure_raises(self, addon_cls): entry = {"op": "op://vault/item/field", "hosts": []} - with patch("mitmproxy_addon.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="authorization required") + with patch(f"{_COMMON}.subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="authorization required" + ) with pytest.raises(RuntimeError, match="authorization required"): - SandcatAddon._resolve_secret_value("KEY", entry) + addon_cls._resolve_secret_value("KEY", entry) - def test_op_reference_in_full_load(self, tmp_path): + def test_op_reference_in_full_load(self, addon_cls, tmp_path): settings = {"secrets": { "API_KEY": {"op": "op://vault/item/field", "hosts": ["api.example.com"]}, }} p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)), \ - patch("mitmproxy_addon.subprocess.run") as mock_run: + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)), \ + patch(f"{_COMMON}.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="resolved-secret\n", stderr="") addon.load(MagicMock()) assert addon.secrets["API_KEY"]["value"] == "resolved-secret" content = env_path.read_text() assert 'export API_KEY="SANDCAT_PLACEHOLDER_API_KEY"' in content - def test_op_failure_logs_warning_and_continues(self, tmp_path): + def test_op_failure_logs_warning_and_continues(self, addon_cls, tmp_path): settings = {"secrets": { "BAD": {"op": "op://vault/item/field", "hosts": []}, "GOOD": {"value": "ok", "hosts": []}, @@ -612,62 +1089,62 @@ def test_op_failure_logs_warning_and_continues(self, tmp_path): p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)), \ - patch("mitmproxy_addon.subprocess.run") as mock_run: + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)), \ + patch(f"{_COMMON}.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="vault not found") addon.load(MagicMock()) assert addon.secrets["BAD"]["value"] == "" assert addon.secrets["GOOD"]["value"] == "ok" - def test_op_token_from_settings(self, tmp_path, monkeypatch): + def test_op_token_from_settings(self, addon_cls, tmp_path, monkeypatch): monkeypatch.delenv("OP_SERVICE_ACCOUNT_TOKEN", raising=False) settings = { "op_service_account_token": "ops_test_token", - "secrets": { - "KEY": {"op": "op://vault/item/field", "hosts": []}, - }, + "secrets": {"KEY": {"op": "op://vault/item/field", "hosts": []}}, } p = tmp_path / "settings.json" p.write_text(json.dumps(settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)), \ - patch("mitmproxy_addon.subprocess.run") as mock_run: + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)), \ + patch(f"{_COMMON}.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="secret\n", stderr="") addon.load(MagicMock()) assert os.environ.get("OP_SERVICE_ACCOUNT_TOKEN") == "ops_test_token" monkeypatch.delenv("OP_SERVICE_ACCOUNT_TOKEN", raising=False) - def test_op_token_env_var_takes_precedence(self, monkeypatch): + def test_op_token_env_var_takes_precedence(self, addon_cls, monkeypatch): monkeypatch.setenv("OP_SERVICE_ACCOUNT_TOKEN", "from_env") - SandcatAddon._configure_op_token("from_settings") + addon_cls._configure_op_token("from_settings") assert os.environ["OP_SERVICE_ACCOUNT_TOKEN"] == "from_env" - def test_op_token_not_set_when_empty(self, monkeypatch): + def test_op_token_not_set_when_empty(self, addon_cls, monkeypatch): monkeypatch.delenv("OP_SERVICE_ACCOUNT_TOKEN", raising=False) - SandcatAddon._configure_op_token("") + addon_cls._configure_op_token("") assert "OP_SERVICE_ACCOUNT_TOKEN" not in os.environ - def test_op_token_not_set_when_none(self, monkeypatch): + def test_op_token_not_set_when_none(self, addon_cls, monkeypatch): monkeypatch.delenv("OP_SERVICE_ACCOUNT_TOKEN", raising=False) - SandcatAddon._configure_op_token(None) + addon_cls._configure_op_token(None) assert "OP_SERVICE_ACCOUNT_TOKEN" not in os.environ # --------------------------------------------------------------------------- -# Settings merging +# Settings merging — pure shared logic on the base class. # --------------------------------------------------------------------------- class TestSettingsMerging: + """Static merge helper lives in the shared library.""" + def test_env_higher_precedence_wins(self): layers = [ {"env": {"A": "user", "B": "user"}}, {"env": {"B": "project"}}, ] - merged = SandcatAddon._merge_settings(layers) + merged = BaseAddon._merge_settings(layers) assert merged["env"] == {"A": "user", "B": "project"} def test_secrets_higher_precedence_wins(self): @@ -680,7 +1157,7 @@ def test_secrets_higher_precedence_wins(self): "KEY2": {"value": "v2-project", "hosts": ["c.com"]}, }}, ] - merged = SandcatAddon._merge_settings(layers) + merged = BaseAddon._merge_settings(layers) assert merged["secrets"]["KEY1"]["value"] == "v1-user" assert merged["secrets"]["KEY2"]["value"] == "v2-project" assert merged["secrets"]["KEY2"]["hosts"] == ["c.com"] @@ -691,7 +1168,7 @@ def test_network_rules_highest_precedence_first(self): {"network": [{"action": "allow", "host": "project.com"}]}, {"network": [{"action": "deny", "host": "local.com"}]}, ] - merged = SandcatAddon._merge_settings(layers) + merged = BaseAddon._merge_settings(layers) assert merged["network"] == [ {"action": "deny", "host": "local.com"}, {"action": "allow", "host": "project.com"}, @@ -703,7 +1180,7 @@ def test_missing_sections_treated_as_empty(self): {"env": {"A": "1"}}, {"network": [{"action": "allow", "host": "*"}]}, ] - merged = SandcatAddon._merge_settings(layers) + merged = BaseAddon._merge_settings(layers) assert merged["env"] == {"A": "1"} assert merged["secrets"] == {} assert merged["network"] == [{"action": "allow", "host": "*"}] @@ -714,7 +1191,7 @@ def test_op_token_highest_precedence_wins(self): {"op_service_account_token": "user_token"}, {"op_service_account_token": "project_token"}, ] - merged = SandcatAddon._merge_settings(layers) + merged = BaseAddon._merge_settings(layers) assert merged["op_service_account_token"] == "project_token" def test_op_token_skips_empty(self): @@ -722,45 +1199,195 @@ def test_op_token_skips_empty(self): {"op_service_account_token": "user_token"}, {"op_service_account_token": ""}, ] - merged = SandcatAddon._merge_settings(layers) + merged = BaseAddon._merge_settings(layers) assert merged["op_service_account_token"] == "user_token" def test_op_token_absent(self): layers = [{"env": {"A": "1"}}] - merged = SandcatAddon._merge_settings(layers) + merged = BaseAddon._merge_settings(layers) assert merged["op_service_account_token"] is None def test_empty_layers_list(self): - merged = SandcatAddon._merge_settings([]) - assert merged == {"env": {}, "secrets": {}, "network": [], - "op_service_account_token": None} + merged = BaseAddon._merge_settings([]) + assert merged == { + "env": {}, + "secrets": {}, + "network": [], + "op_service_account_token": None, + } +# --------------------------------------------------------------------------- +# Multi-file loading — exercises the shared layer-reading loop. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("addon_cls", ADDONS) class TestMultiFileLoading: - def test_loads_multiple_settings_files(self, tmp_path): - user_settings = {"env": {"A": "user"}, "network": [{"action": "allow", "host": "user.com"}]} + def test_loads_multiple_settings_files(self, addon_cls, tmp_path): + user_settings = { + "env": {"A": "user"}, + "network": [{"action": "allow", "host": "user.com"}], + } project_settings = {"env": {"A": "project", "B": "project"}} (tmp_path / "user.json").write_text(json.dumps(user_settings)) (tmp_path / "project.json").write_text(json.dumps(project_settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [str(tmp_path / "user.json"), str(tmp_path / "project.json")]), \ - patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", + [str(tmp_path / "user.json"), str(tmp_path / "project.json")]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) assert addon.env == {"A": "project", "B": "project"} assert addon.network_rules == [{"action": "allow", "host": "user.com"}] - def test_skips_missing_files(self, tmp_path): + def test_skips_missing_files(self, addon_cls, tmp_path): user_settings = {"env": {"A": "user"}} (tmp_path / "user.json").write_text(json.dumps(user_settings)) env_path = tmp_path / "sandcat.env" - addon = SandcatAddon() - with patch("mitmproxy_addon.SETTINGS_PATHS", [ + addon = addon_cls() + with patch(f"{_COMMON}.SETTINGS_PATHS", [ str(tmp_path / "user.json"), str(tmp_path / "missing.json"), str(tmp_path / "also-missing.json"), - ]), patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(env_path)): + ]), patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): addon.load(MagicMock()) assert addon.env == {"A": "user"} + +# --------------------------------------------------------------------------- +# Shared library helpers — purely unit tests on the base class. +# --------------------------------------------------------------------------- + +class TestCommonHelpers: + def test_is_truthy_true_values(self): + for v in ["1", "true", "TRUE", "yes", "ON", " on ", True]: + assert BaseAddon._is_truthy(v) is True + + def test_is_truthy_false_values(self): + for v in ["0", "false", "no", "off", "", None, False]: + assert BaseAddon._is_truthy(v) is False + + def test_default_is_textual_content_type_returns_true(self): + # Base default: any content type passes. + assert BaseAddon._is_textual_content_type("") is True + assert BaseAddon._is_textual_content_type(None) is True + assert BaseAddon._is_textual_content_type("application/octet-stream") is True + + def test_default_is_streaming_request_false(self): + addon = BaseAddon() + flow = _make_flow(host="example.com") + assert addon._is_streaming_request(flow) is False + + def test_default_basic_auth_helpers_are_inert(self): + assert BaseAddon._basic_auth_contains_placeholder("Basic xxx", "P") is False + assert BaseAddon._replace_placeholder_in_basic_auth("Basic xxx", "P", "v") == ("Basic xxx", False) + + def test_default_normalize_authorization_header_is_passthrough(self): + addon = BaseAddon() + assert addon._normalize_authorization_header("Bearer abc ") == "Bearer abc " + + def test_auth_debug_summary_missing(self): + assert BaseAddon._auth_debug_summary(None).startswith("authorization=") + assert BaseAddon._auth_debug_summary("").startswith("authorization=") + + def test_auth_debug_summary_bearer_format(self): + summary = BaseAddon._auth_debug_summary("Bearer abc") + assert summary.startswith("bearer len=3") + assert "sha256_12=" in summary + + def test_auth_debug_summary_basic_format(self): + import base64 + + encoded = base64.b64encode(b"user:pass").decode("ascii") + summary = BaseAddon._auth_debug_summary(f"Basic {encoded}") + assert summary.startswith("basic decoded_len=9") + + def test_auth_debug_summary_other(self): + assert BaseAddon._auth_debug_summary("Digest xyz").startswith("other len=") + + +# --------------------------------------------------------------------------- +# Cursor textual content-type gate (direct unit tests). +# --------------------------------------------------------------------------- + +class TestCursorTextualContentType: + @pytest.mark.parametrize("ct", [ + "application/json", + "application/json; charset=utf-8", + "text/plain", + "text/html", + "application/x-www-form-urlencoded", + "application/vnd.api+json", + "application/atom+xml", + "application/javascript", + "application/graphql", + "application/xml", + ]) + def test_textual_types(self, ct): + assert CursorAddon._is_textual_content_type(ct) is True + + @pytest.mark.parametrize("ct", [ + "", + None, + "application/octet-stream", + "image/png", + "application/connect+proto", + "application/grpc", + ]) + def test_non_textual_types(self, ct): + assert CursorAddon._is_textual_content_type(ct) is False + + +# --------------------------------------------------------------------------- +# Cursor debug flag — wired up via SANDCAT_MITM_DEBUG. +# --------------------------------------------------------------------------- + +class TestCursorDebugFlag: + def test_debug_disabled_by_default(self, tmp_path, monkeypatch): + monkeypatch.delenv("SANDCAT_MITM_DEBUG", raising=False) + settings = {"env": {"A": "1"}} + p = tmp_path / "settings.json" + p.write_text(json.dumps(settings)) + env_path = tmp_path / "sandcat.env" + addon = CursorAddon() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): + addon.load(MagicMock()) + assert addon.debug_enabled is False + + def test_debug_enabled_via_env_var(self, tmp_path, monkeypatch): + monkeypatch.setenv("SANDCAT_MITM_DEBUG", "1") + settings = {"env": {"A": "1"}} + p = tmp_path / "settings.json" + p.write_text(json.dumps(settings)) + env_path = tmp_path / "sandcat.env" + addon = CursorAddon() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): + addon.load(MagicMock()) + assert addon.debug_enabled is True + + def test_debug_enabled_via_settings(self, tmp_path, monkeypatch): + monkeypatch.delenv("SANDCAT_MITM_DEBUG", raising=False) + settings = {"env": {"SANDCAT_MITM_DEBUG": "true"}} + p = tmp_path / "settings.json" + p.write_text(json.dumps(settings)) + env_path = tmp_path / "sandcat.env" + addon = CursorAddon() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): + addon.load(MagicMock()) + assert addon.debug_enabled is True + + def test_claude_does_not_read_debug_flag(self, tmp_path, monkeypatch): + monkeypatch.setenv("SANDCAT_MITM_DEBUG", "1") + settings = {"env": {"A": "1"}} + p = tmp_path / "settings.json" + p.write_text(json.dumps(settings)) + env_path = tmp_path / "sandcat.env" + addon = ClaudeAddon() + with patch(f"{_COMMON}.SETTINGS_PATHS", [str(p)]), \ + patch(f"{_COMMON}.SANDCAT_ENV_PATH", str(env_path)): + addon.load(MagicMock()) + # Claude variant doesn't override _on_settings_merged → flag stays False. + assert addon.debug_enabled is False