Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions .github/workflows/build-mitmproxy-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ on:
branches: [master]
paths:
- 'images/mitmproxy/**'
- 'images/mitmproxy.env'
- '.github/workflows/build-mitmproxy-image.yml'
pull_request:
paths:
- 'images/mitmproxy/**'
- 'images/mitmproxy.env'
- '.github/workflows/build-mitmproxy-image.yml'
schedule:
- cron: '0 6 * * 1' # Weekly Monday 6am — rebuild against latest mitmproxy
- cron: '0 6 * * 1' # Weekly Monday 6am — build-only canary against upstream mitmproxy:latest
workflow_dispatch:

jobs:
Expand All @@ -35,26 +37,51 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Load pinned mitmproxy version
# Single source of truth — also consumed by the CLI (SCT_MITMPROXY_VERSION
# in cli/lib/constants.bash) and the contract test that checks they match.
if: github.event_name != 'schedule'
run: grep -m1 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV"

- name: Use latest mitmproxy for the scheduled canary build
# The weekly canary builds against upstream mitmproxy:latest to surface
# breaking upstream releases early (a red run is the alarm). It is
# build-only — nothing is pushed (see `push:` below) — so an
# unreviewed upstream image can never reach any published tag.
if: github.event_name == 'schedule'
run: echo "MITMPROXY_VERSION=latest" >> "$GITHUB_ENV"

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/virtuslab/sandcat-mitmproxy-op
# Tag policy: every published image contains the PINNED mitmproxy
# (12.2.3 from images/mitmproxy.env). `latest` simply means the
# latest master build of OUR image — so projects that reference
# ghcr `latest` (including those generated before pinning) get the
# pinned base too. The <version> tag is published from master
# pushes and master dispatches only, so a dispatch from a feature
# branch can never overwrite it (it gets branch + sha tags).
# Scheduled canary builds are never pushed at all.
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') }}
type=raw,value=${{ env.MITMPROXY_VERSION }},enable=${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') }}
type=sha,enable=${{ github.event_name != 'schedule' && github.event_name != 'pull_request' }}
type=ref,event=branch,enable=${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master' }}
type=ref,event=pr,enable=${{ github.event_name == 'pull_request' }}

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: images/mitmproxy
file: images/mitmproxy/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' && github.event_name != 'schedule' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Skip cache on scheduled builds to pick up upstream mitmproxy:latest changes
build-args: |
MITMPROXY_VERSION=${{ env.MITMPROXY_VERSION }}
# Skip cache on scheduled canary builds to pick up upstream mitmproxy:latest changes
cache-from: ${{ github.event_name != 'schedule' && 'type=gha' || '' }}
cache-to: type=gha,mode=max
40 changes: 33 additions & 7 deletions .github/workflows/build-mitmproxy-pass-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ on:
branches: [master]
paths:
- 'images/mitmproxy-pass/**'
- 'images/mitmproxy.env'
- '.github/workflows/build-mitmproxy-pass-image.yml'
pull_request:
paths:
- 'images/mitmproxy-pass/**'
- 'images/mitmproxy.env'
- '.github/workflows/build-mitmproxy-pass-image.yml'
schedule:
- cron: '0 6 * * 1' # Weekly Monday 6am — rebuild against latest mitmproxy
- cron: '0 6 * * 1' # Weekly Monday 6am — build-only canary against upstream mitmproxy:latest
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -40,30 +42,54 @@ jobs:
# test. Strip comments/blank lines before exporting to $GITHUB_ENV.
run: grep -E '^[A-Za-z_][A-Za-z0-9_]*=' images/mitmproxy-pass/pass-cli.env >> "$GITHUB_ENV"

- name: Load pinned mitmproxy version
# Single source of truth — also consumed by the CLI (SCT_MITMPROXY_VERSION
# in cli/lib/constants.bash) and the contract test that checks they match.
if: github.event_name != 'schedule'
run: grep -m1 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV"

- name: Use latest mitmproxy for the scheduled canary build
# The weekly canary builds against upstream mitmproxy:latest to surface
# breaking upstream releases early (a red run is the alarm). It is
# build-only — nothing is pushed (see `push:` below) — so an
# unreviewed upstream image can never reach any published tag.
if: github.event_name == 'schedule'
run: echo "MITMPROXY_VERSION=latest" >> "$GITHUB_ENV"

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/virtuslab/sandcat-mitmproxy-pass
# Tag policy: every published image contains the PINNED mitmproxy
# (12.2.3 from images/mitmproxy.env). `latest` simply means the
# latest master build of OUR image — so projects that reference
# ghcr `latest` (including those generated before pinning) get the
# pinned base too. The <version> tag is published from master
# pushes and master dispatches only, so a dispatch from a feature
# branch can never overwrite it (it gets branch + sha tags).
# Scheduled canary builds are never pushed at all.
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') }}
type=raw,value=${{ env.MITMPROXY_VERSION }},enable=${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') }}
type=sha,enable=${{ github.event_name != 'schedule' && github.event_name != 'pull_request' }}
type=ref,event=branch,enable=${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master' }}
type=ref,event=pr,enable=${{ github.event_name == 'pull_request' }}

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: images/mitmproxy-pass
file: images/mitmproxy-pass/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name != 'pull_request' && github.event_name != 'schedule' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
MITMPROXY_VERSION=${{ env.MITMPROXY_VERSION }}
PASS_CLI_VERSION=${{ env.PASS_CLI_VERSION }}
PASS_CLI_SHA256_X86_64=${{ env.PASS_CLI_SHA256_X86_64 }}
PASS_CLI_SHA256_AARCH64=${{ env.PASS_CLI_SHA256_AARCH64 }}
# Skip cache on scheduled builds to pick up upstream mitmproxy:latest changes
# Skip cache on scheduled canary builds to pick up upstream mitmproxy:latest changes
cache-from: ${{ github.event_name != 'schedule' && 'type=gha' || '' }}
cache-to: type=gha,mode=max
2 changes: 2 additions & 0 deletions .github/workflows/cli-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ on:
branches: [master]
paths:
- 'cli/**'
- 'images/mitmproxy.env'
- '.github/workflows/cli-tests.yml'
pull_request:
paths:
- 'cli/**'
- 'images/mitmproxy.env'
- '.github/workflows/cli-tests.yml'
workflow_dispatch: # Allow manual trigger

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ into the container's copy of `hooks.json`.

```bash
# Install rtk locally (needed once on the host)
brew install rtk # or: curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh
brew install rtk # or: curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/b34be37caf3796b69a50952a28e60e32b5daad43/install.sh | RTK_VERSION=v0.45.0 sh

# Register the cursor hook in your host ~/.cursor/hooks.json
rtk init -g --hook-only --auto-patch --agent cursor
Expand Down Expand Up @@ -1606,7 +1606,7 @@ enough for most tools — but some runtimes bring their own CA handling:
If your organization runs internal HTTPS services (e.g. an on-prem Nexus,
GitLab, Artifactory) signed by an internal CA or with a self-signed
certificate, sandcat's mitmproxy will fail to validate those upstreams by
default — the `mitmproxy/mitmproxy:latest` image ships a stock Debian
default — the `mitmproxy/mitmproxy` image ships a stock Debian
public-CA bundle and does not know about your internal CA.

Add the CA(s) to `upstream_ca_bundles` in
Expand Down
27 changes: 23 additions & 4 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,24 @@ docker build \

PAT detection relies on the wording of `pass-cli info` output. Because the binary is pinned by version **and** sha256, that output cannot change without a deliberate bump. A contract test (`TestPassCliPatContract`) locks the detection regex against golden samples tagged with `PASS_CLI_VERSION`. When you bump `pass-cli.env`, you must also re-capture those samples — see [`cli/test/mitmproxy/fixtures/pass-cli/README.md`](test/mitmproxy/fixtures/pass-cli/README.md) — or CI will fail.

Note: Cursor agent support uses placeholder-based API key substitution and
Sandcat-managed CLI settings (`cursor.cli` in settings — permissions, model,
network flags). Put the API key in `secrets.CURSOR_API_KEY`, not in
`cursor.cli`. See the main README Cursor section for details.
##### Bumping the pinned mitmproxy version

The mitmproxy container image version is pinned in two places to ensure consistency and enable reproducible builds:

- **`cli/lib/constants.bash`** — `SCT_MITMPROXY_VERSION` (used by the CLI)
- **`images/mitmproxy.env`** — `MITMPROXY_VERSION` (used by Docker build)

To bump the pinned mitmproxy version:

1. Edit both files above to the same new version (e.g., `13.0.0`)
2. Push the changes to a branch
3. The contract test `mitmproxy_version.bats` will verify the two values match; CI will fail if they diverge
4. Merge to master — the image build workflows are triggered by changes to `images/mitmproxy.env` and publish the new versioned `ghcr.io/virtuslab/sandcat-mitmproxy-op` and `ghcr.io/virtuslab/sandcat-mitmproxy-pass` tags, moving `latest` to the same build (every published image contains the pinned mitmproxy; `latest` just means the newest master build)
5. The weekly cron job is a build-only canary against upstream `mitmproxy:latest` — a red run signals a breaking upstream release; nothing is ever published from it

Generated projects reference the pinned version from the CLI-side constant, so projects created with `sandcat init` always use the stable versioned image.

**Merge-day race:** after merging a bump, wait for both `build-mitmproxy-image.yml` and `build-mitmproxy-pass-image.yml` to finish publishing the new versioned ghcr tags before creating or upgrading a secret-provider (1password/protonpass) project against that version. In the window between merge and publish, `docker compose up` fails loudly with `manifest unknown`; it's safe to retry once the workflows complete. Provider `none` is unaffected since it resolves to the public Docker Hub `mitmproxy/mitmproxy` tag, which already exists.

#### `sandcat init devcontainer`

Expand All @@ -114,6 +128,11 @@ Options:
- `--secret-provider` - `none`, `1password`, or `protonpass` (optional; default `none`)
- `--1password` - Deprecated alias for `--secret-provider 1password`

Note: Cursor agent support uses placeholder-based API key substitution and
Sandcat-managed CLI settings (`cursor.cli` in settings — permissions, model,
network flags). Put the API key in `secrets.CURSOR_API_KEY`, not in
`cursor.cli`. See the main README Cursor section for details.

#### `sandcat init settings`

Creates a network settings file for the proxy.
Expand Down
8 changes: 4 additions & 4 deletions cli/lib/composefile.bash
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,14 @@ apply_secret_provider() {
return 0
;;
1password)
yq -i '
.services.mitmproxy.image = "ghcr.io/virtuslab/sandcat-mitmproxy-op:latest" |
mitm_ver="$SCT_MITMPROXY_VERSION" yq -i '
.services.mitmproxy.image = "ghcr.io/virtuslab/sandcat-mitmproxy-op:" + env(mitm_ver) |
.services.mitmproxy.environment = ["OP_SERVICE_ACCOUNT_TOKEN"]
' "$compose_file"
;;
protonpass)
yq -i '
.services.mitmproxy.image = "ghcr.io/virtuslab/sandcat-mitmproxy-pass:latest" |
mitm_ver="$SCT_MITMPROXY_VERSION" yq -i '
.services.mitmproxy.image = "ghcr.io/virtuslab/sandcat-mitmproxy-pass:" + env(mitm_ver) |
.services.mitmproxy.environment = ["PROTON_PASS_PERSONAL_ACCESS_TOKEN"]
' "$compose_file"
;;
Expand Down
5 changes: 5 additions & 0 deletions cli/lib/constants.bash
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@
sct_home() { echo "$HOME/.config/sandcat"; }

export SCT_PROJECT_DIR='.sandcat'

# Pinned mitmproxy image version used by CLI-generated compose files.
# Keep in sync with the build-side counterpart in images/mitmproxy.env —
# a contract test asserts the two stay equal.
export SCT_MITMPROXY_VERSION="12.2.3"
5 changes: 4 additions & 1 deletion cli/lib/devcontainer.bash
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/usr/bin/env bash

# shellcheck source=constants.bash
source "$SCT_LIBDIR/constants.bash"
# shellcheck source=stacks.bash
source "$SCT_LIBDIR/stacks.bash"
# shellcheck source=agents.bash
Expand Down Expand Up @@ -288,5 +290,6 @@ customize_agent_templates() {
"$devcontainer_dir/sandcat/compose-proxy.yml" \
"__AGENT_MITM_ADDON__" "$mitm_addon_file" \
"__MITM_HTTP2__" "$mitm_http2" \
"__AGENT_MITM_STREAMING_FLAGS__" "$mitm_streaming_flags"
"__AGENT_MITM_STREAMING_FLAGS__" "$mitm_streaming_flags" \
"__MITMPROXY_VERSION__" "$SCT_MITMPROXY_VERSION"
}
10 changes: 9 additions & 1 deletion cli/lib/rtk.bash
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ sct_rtk_enabled() {
# binary serves every supported agent; per-agent hook wiring happens
# at container start via sct_rtk_user_init_block.
#
# The install script URL is pinned to a commit SHA to freeze the fetched
# shell code and close the arbitrary-code-via-master vector. The binary
# itself is pinned separately via RTK_VERSION, which install.sh verifies
# with a SHA-256 check against the release's checksums.txt before
# installing — so both the script and the binary it fetches are pinned.
# When bumping rtk, update the commit SHA and RTK_VERSION together.
#
# Emits an empty output when the feature is disabled so the caller can
# unconditionally append it to Dockerfile fragments.
sct_rtk_docker_install_block() {
Expand All @@ -26,7 +33,8 @@ sct_rtk_docker_install_block() {
# agent-home volume can't mask the binary on upgrade. Disable at init
# time with `sandcat init --features no-rtk` or `SANDCAT_RTK=false`.
USER root
RUN curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | RTK_INSTALL_DIR=/usr/local/bin sh
RUN curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/b34be37caf3796b69a50952a28e60e32b5daad43/install.sh | \
RTK_INSTALL_DIR=/usr/local/bin RTK_VERSION=v0.45.0 sh
USER vscode
EOF
}
Expand Down
2 changes: 1 addition & 1 deletion cli/templates/devcontainer/sandcat/compose-proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ services:
start_period: 120s

mitmproxy:
image: mitmproxy/mitmproxy:latest
image: mitmproxy/mitmproxy:__MITMPROXY_VERSION__
# Delete the stale dns.conf sentinel before mitmweb starts (see healthcheck
# below). The mitmproxy-config volume persists across restarts, so a dns.conf
# left by a previous run would satisfy the healthcheck immediately — letting
Expand Down
46 changes: 46 additions & 0 deletions cli/test/compat/mitmproxy_version.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bats
# Contract: cli/lib/constants.bash's SCT_MITMPROXY_VERSION (used by the CLI to
# render pinned image refs) must stay equal to images/mitmproxy.env's
# MITMPROXY_VERSION (used by the build workflows to publish those refs).
# Also guards both Dockerfiles against a stray re-hardcode of the base image
# tag once it's parameterized.

setup() {
load test_helper
# shellcheck source=../../lib/constants.bash
source "$SCT_LIBDIR/constants.bash"

REPO_ROOT="$SCT_ROOT/.."
ENV_FILE="$REPO_ROOT/images/mitmproxy.env"
}

@test "images/mitmproxy.env exists" {
[ -f "$ENV_FILE" ]
}

@test "SCT_MITMPROXY_VERSION matches MITMPROXY_VERSION in images/mitmproxy.env" {
local env_version
env_version="$(grep -m1 '^MITMPROXY_VERSION=' "$ENV_FILE" | cut -d= -f2-)"

assert_equal "$SCT_MITMPROXY_VERSION" "$env_version"
}

@test "images/mitmproxy/Dockerfile takes MITMPROXY_VERSION as a build arg with no default" {
run grep -xF 'ARG MITMPROXY_VERSION' "$REPO_ROOT/images/mitmproxy/Dockerfile"
assert_success
}

@test "images/mitmproxy/Dockerfile FROM is parameterized on MITMPROXY_VERSION" {
run grep -xF 'FROM mitmproxy/mitmproxy:${MITMPROXY_VERSION}' "$REPO_ROOT/images/mitmproxy/Dockerfile"
assert_success
}

@test "images/mitmproxy-pass/Dockerfile takes MITMPROXY_VERSION as a build arg with no default" {
run grep -xF 'ARG MITMPROXY_VERSION' "$REPO_ROOT/images/mitmproxy-pass/Dockerfile"
assert_success
}

@test "images/mitmproxy-pass/Dockerfile FROM is parameterized on MITMPROXY_VERSION" {
run grep -xF 'FROM mitmproxy/mitmproxy:${MITMPROXY_VERSION}' "$REPO_ROOT/images/mitmproxy-pass/Dockerfile"
assert_success
}
4 changes: 2 additions & 2 deletions cli/test/composefile/composefile.bats
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ YAML

apply_secret_provider "$proxy_compose" "1password"

yq -e '.services.mitmproxy.image == "ghcr.io/virtuslab/sandcat-mitmproxy-op:latest"' "$proxy_compose"
yq -e '.services.mitmproxy.image == "ghcr.io/virtuslab/sandcat-mitmproxy-op:'"$SCT_MITMPROXY_VERSION"'"' "$proxy_compose"
yq -e '.services.mitmproxy.environment[] | select(. == "OP_SERVICE_ACCOUNT_TOKEN")' "$proxy_compose"
}

Expand All @@ -545,7 +545,7 @@ YAML

apply_secret_provider "$proxy_compose" "protonpass"

yq -e '.services.mitmproxy.image == "ghcr.io/virtuslab/sandcat-mitmproxy-pass:latest"' "$proxy_compose"
yq -e '.services.mitmproxy.image == "ghcr.io/virtuslab/sandcat-mitmproxy-pass:'"$SCT_MITMPROXY_VERSION"'"' "$proxy_compose"
yq -e '.services.mitmproxy.environment[] | select(. == "PROTON_PASS_PERSONAL_ACCESS_TOKEN")' "$proxy_compose"
}

Expand Down
18 changes: 18 additions & 0 deletions cli/test/init/extensions.bats
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,24 @@ teardown() {
assert_failure
}

@test "customize_agent_templates pins mitmproxy image version" {
{
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 "mitmproxy/mitmproxy:$SCT_MITMPROXY_VERSION" "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_success

# Placeholder must be fully resolved.
run grep '__MITMPROXY_VERSION__' "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml"
assert_failure
}

@test "customize_agent_templates adds cursor bootstrap settings" {
{
echo 'include: []'
Expand Down
Loading
Loading