From ddb51e16005c8a3f1589b9dce3603aa88b1e1f59 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Mon, 17 Aug 2026 13:31:37 +0200 Subject: [PATCH 01/13] docs(sdd): spec + plan for #20 image version pinning (Phase 1) Pin the mitmproxy image chain to an exact version (12.2.3): the public image referenced by generated compose-proxy.yml, and the two derived ghcr secret-provider images. Two-file source of truth (CLI constants + images/mitmproxy.env) enforced by a contract test. Weekly cron becomes a latest-only tracking channel; versioned tags publish only from master pushes and are never overwritten. rtk install script pinned to a commit SHA (script-level freeze; binary pin deferred until rtk has stable releases). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ --- .../plans/2026-08-17-pin-image-versions.md | 118 ++++++++++++++++++ .../2026-08-17-pin-image-versions-design.md | 89 +++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-pin-image-versions.md create mode 100644 docs/superpowers/specs/2026-08-17-pin-image-versions-design.md diff --git a/docs/superpowers/plans/2026-08-17-pin-image-versions.md b/docs/superpowers/plans/2026-08-17-pin-image-versions.md new file mode 100644 index 00000000..5583afea --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-pin-image-versions.md @@ -0,0 +1,118 @@ +# Pin Image Versions (issue #20, Phase 1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Pin the mitmproxy image chain (public image in generated projects, both derived ghcr secret-provider images) to an exact version with a two-file source of truth enforced by a contract test; make the weekly cron rebuild only the `latest` tracking channel; freeze the rtk install script to a commit SHA. + +**Architecture:** CLI side gets `SCT_MITMPROXY_VERSION` in `cli/lib/constants.bash`, consumed by a new `__MITMPROXY_VERSION__` placeholder in the compose-proxy template and by `apply_secret_provider`. Build side gets `images/mitmproxy.env`, consumed by both image workflows as `--build-arg` into `ARG MITMPROXY_VERSION` (no default) in both Dockerfiles. A contract bats test keeps both values equal. Cron passes `MITMPROXY_VERSION=latest` and tags only `latest`; master pushes tag the version. + +**Tech Stack:** bash + yq, bats, GitHub Actions, Dockerfiles. + +## Global Constraints + +- Pinned version at introduction: **12.2.3**. +- CLI constant: `SCT_MITMPROXY_VERSION="12.2.3"` in `cli/lib/constants.bash`. +- Build env file: `images/mitmproxy.env` with `MITMPROXY_VERSION=12.2.3` (comment header explaining it's the build-side source of truth, mirroring `images/mitmproxy-pass/pass-cli.env` style). +- Template placeholder: `__MITMPROXY_VERSION__` in `cli/templates/devcontainer/sandcat/compose-proxy.yml`, replaced in `customize_agent_templates`'s existing `apply_inline_placeholders` call. +- `apply_secret_provider` writes `ghcr.io/virtuslab/sandcat-mitmproxy-op:` / `-pass:` using `$SCT_MITMPROXY_VERSION`. +- Both `images/*/Dockerfile`: `ARG MITMPROXY_VERSION` (no default) + `FROM mitmproxy/mitmproxy:${MITMPROXY_VERSION}`. +- Workflows: master push → build with pinned version, tags = version + sha (NOT latest); schedule → build with `MITMPROXY_VERSION=latest`, tag = latest only; PR → build pinned, no push (preserve current PR push behavior). +- rtk: `raw.githubusercontent.com/rtk-ai/rtk//install.sh` with a comment documenting the script-vs-binary pin limitation. +- `regression.bats` `assert_proxy_service` updated from `mitmproxy/mitmproxy:latest` to the pinned reference — a mandated assertion change, not a weakening. +- All existing bats suites green. + +--- + +### Task 1: CLI-side pinning + +**Files:** +- Modify: `cli/lib/constants.bash` (add `SCT_MITMPROXY_VERSION="12.2.3"` with a comment pointing at `images/mitmproxy.env` + the contract test) +- Modify: `cli/templates/devcontainer/sandcat/compose-proxy.yml` (`image: mitmproxy/mitmproxy:latest` → `image: mitmproxy/mitmproxy:__MITMPROXY_VERSION__`) +- Modify: `cli/lib/devcontainer.bash` (add the placeholder pair to the existing `apply_inline_placeholders` call that already handles `__MITM_HTTP2__` — around line 283-286) +- Modify: `cli/lib/composefile.bash` (`apply_secret_provider`: both ghcr images get `:` + `$SCT_MITMPROXY_VERSION`; use `env()` injection in yq, matching file style) +- Test: `cli/test/init/regression.bats` (`assert_proxy_service` — pinned image reference), `cli/test/init/extensions.bats` (add: generated compose-proxy.yml contains `mitmproxy/mitmproxy:12.2.3` and no unresolved `__MITMPROXY_VERSION__`), `cli/test/composefile/composefile.bats` (secret-provider tests if they assert the `:latest` ghcr tags — re-point to versioned) + +**Interfaces:** +- Consumes: existing placeholder-replacement flow. +- Produces: `SCT_MITMPROXY_VERSION` (Task 2's contract test reads it; Task 4's docs reference it). + +**Steps:** (implementer works test-first per file group) + +- [ ] **Step 1**: Grep current assertions: `grep -rn "mitmproxy/mitmproxy:latest\|sandcat-mitmproxy-op:latest\|sandcat-mitmproxy-pass:latest" cli/` — every hit is either a change site or a test to update. List them in the report. +- [ ] **Step 2**: Add the constant to `constants.bash`; template placeholder; wire `apply_inline_placeholders`; update `apply_secret_provider`. +- [ ] **Step 3**: Update/extend the tests found in Step 1. New extensions.bats test asserts BOTH: resolved pinned image AND `run grep '__MITMPROXY_VERSION__' ...; assert_failure`. +- [ ] **Step 4**: `cd cli && ./run-tests.bash test/init/ test/composefile/` green; then full surface. +- [ ] **Step 5**: Commit: `security(cli): pin mitmproxy image references to SCT_MITMPROXY_VERSION (#20)` + +--- + +### Task 2: Build-side pinning + workflow tag policy + +**Files:** +- Create: `images/mitmproxy.env` (`MITMPROXY_VERSION=12.2.3` + header comment listing consumers) +- Modify: `images/mitmproxy/Dockerfile`, `images/mitmproxy-pass/Dockerfile` (`ARG MITMPROXY_VERSION` no default + parameterized FROM; update the header comment that says "Rebuilt weekly to track mitmproxy:latest" to describe the new dual-channel policy) +- Modify: `.github/workflows/build-mitmproxy-image.yml`, `.github/workflows/build-mitmproxy-pass-image.yml`: + - add `images/mitmproxy.env` to both `paths` trigger lists + - a step that reads the env file into `$GITHUB_ENV` (skip on schedule) + - build-arg: `MITMPROXY_VERSION=${{ github.event_name == 'schedule' && 'latest' || env.MITMPROXY_VERSION }}` (or equivalent two-step logic — implementer picks the cleanest correct form) + - metadata-action tags: replace the current list with conditional logic — schedule → `latest` only; master push → `` + `sha`; PR → current PR-tag behavior, no push. Verify with `docker/metadata-action` `enable=` expressions or split into two metadata steps guarded by `if:`. +- Test: new contract test `cli/test/compat/mitmproxy_version.bats` — parses `SCT_MITMPROXY_VERSION` from `cli/lib/constants.bash` and `MITMPROXY_VERSION` from `images/mitmproxy.env`, asserts equal. Also asserts both Dockerfiles contain `ARG MITMPROXY_VERSION` and the parameterized FROM (guards against a stray re-hardcode). + +**Interfaces:** +- Consumes: `SCT_MITMPROXY_VERSION` from Task 1. +- Produces: `images/mitmproxy.env` (Task 4 documents the bump procedure around it). + +**Steps:** + +- [ ] **Step 1**: Write the contract test first; run — fails (env file missing). +- [ ] **Step 2**: Create env file, patch both Dockerfiles. +- [ ] **Step 3**: Patch both workflows. Validate YAML: `yq . .github/workflows/build-mitmproxy-image.yml >/dev/null` (and actionlint if available — check `command -v actionlint`). +- [ ] **Step 4**: Local build sanity: `docker build --build-arg MITMPROXY_VERSION=12.2.3 -f images/mitmproxy/Dockerfile images/mitmproxy` succeeds; same with `MITMPROXY_VERSION=latest`. +- [ ] **Step 5**: Contract test green + full bats surface unaffected. +- [ ] **Step 6**: Commit: `security(images): pin mitmproxy base via images/mitmproxy.env; cron rebuilds latest only (#20)` + +--- + +### Task 3: rtk install-script pin + +**Files:** +- Modify: `cli/lib/rtk.bash` (`sct_rtk_docker_install_block`) +- Test: `cli/test/rtk/rtk.bats` (if it asserts the master URL — update; add assertion that the URL contains a 40-char SHA) + +**Steps:** + +- [ ] **Step 1**: Resolve the current rtk master commit: `gh api repos/rtk-ai/rtk/commits/master --jq .sha`. Record it in the report. +- [ ] **Step 2**: Replace `raw.githubusercontent.com/rtk-ai/rtk/master/install.sh` with `.../rtk//install.sh`. Extend the comment: script pinned by SHA (freezes the fetched shell code); rtk's install.sh itself downloads a binary release, so the binary is NOT fully pinned until rtk publishes stable releases — revisit then. +- [ ] **Step 3**: `curl -fsSL | head -5` — sanity that the pinned raw URL serves the script. +- [ ] **Step 4**: `cd cli && ./run-tests.bash test/rtk/` green. +- [ ] **Step 5**: Commit: `security(rtk): pin install script to commit SHA (#20)` + +--- + +### Task 4: Docs — bump procedure + README touch-ups + +**Files:** +- Modify: `README.md` and/or `cli/README.md` + +**Steps:** + +- [ ] **Step 1**: `grep -n "mitmproxy:latest\|mitmproxy/mitmproxy" README.md cli/README.md` — update stale references to reflect pinning. +- [ ] **Step 2**: Add a short "Bumping the pinned mitmproxy version" subsection (likely cli/README.md near the pass-cli.env docs): edit `cli/lib/constants.bash` + `images/mitmproxy.env` (2 lines), the contract test enforces sync, master push publishes the new ghcr version tags, cron only refreshes `latest`. +- [ ] **Step 3**: Commit: `docs: document pinned mitmproxy version + bump procedure` + +--- + +### Task 5: Hands-on integration verification + +**Files:** none (evidence for PR body). + +- [ ] **Step 1**: Fresh project, provider none: generated `compose-proxy.yml` has `image: mitmproxy/mitmproxy:12.2.3`, no unresolved placeholder; `docker compose up -d --build` (pulls the pinned tag) → all healthy; `curl https://github.com` from agent → 200. +- [ ] **Step 2**: Fresh project, `--secret-provider 1password`: generated compose has `ghcr.io/virtuslab/sandcat-mitmproxy-op:12.2.3`. Rendering-only assertion (`docker compose config`) — the tag publishes to ghcr only after this lands on master; note that in the report. Do NOT `up` this variant. +- [ ] **Step 3**: Local image build both ways (pinned + latest build-arg) — already done in Task 2 Step 4; reference results. +- [ ] **Step 4**: Teardown; write `.superpowers/sdd/2026-08-17-pin-image-versions/task-5-report.md`. + +## Out of scope + +- Base images (devcontainers/base, debian:trixie-slim) — follow-up. +- Agent CLI + devbox installers — self-updating tools, low pin value. +- Renovate automation (Phase 2), digest pinning (Phase 3). diff --git a/docs/superpowers/specs/2026-08-17-pin-image-versions-design.md b/docs/superpowers/specs/2026-08-17-pin-image-versions-design.md new file mode 100644 index 00000000..73a232bb --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-pin-image-versions-design.md @@ -0,0 +1,89 @@ +# Pin image versions (issue #20, Phase 1) — Design + +## Goal + +Remove `latest`-tag supply-chain exposure from sandcat's security-critical image chain: the mitmproxy image (TLS interception + secret substitution) and the two derived secret-provider images (`sandcat-mitmproxy-op`, `sandcat-mitmproxy-pass`). Also freeze the rtk install script, currently fetched from a third-party repo's moving `master`. + +## Motivation + +`mitmproxy/mitmproxy:latest` sees all agent plaintext and holds real secret values. Today: + +- the generated `compose-proxy.yml` references `mitmproxy/mitmproxy:latest` — every `docker compose pull` silently swaps the security boundary for whatever upstream published; +- both `images/*/Dockerfile` build `FROM mitmproxy/mitmproxy:latest` and a weekly cron republishes `ghcr.io/virtuslab/sandcat-mitmproxy-{op,pass}:latest` — the tag users' projects reference is overwritten in place every Monday; +- rtk installs via `raw.githubusercontent.com/rtk-ai/rtk/master/install.sh` — any push to that repo's master executes as root in every sandcat image build. + +## Decisions (made with the maintainer) + +1. **Pin exact version** (`12.2.3` at time of writing), bumped manually; automation (Renovate) is Phase 2. +2. **Versioned tags are immutable; `latest` remains a tracking channel.** The weekly cron rebuilds and pushes **only `latest`** (built `FROM mitmproxy/mitmproxy:latest`). Versioned tags (`sandcat-mitmproxy-op:12.2.3`) are published only by a master push (a bump commit) and never overwritten by cron. +3. **Generated projects reference versioned tags** — both the public mitmproxy image (provider `none`) and the ghcr images (providers `1password`/`protonpass`). + +## Version source of truth — two files, one contract test + +The version is needed on two sides that don't share a runtime: + +- **CLI side** (ships in the installed sandcat tarball): `SCT_MITMPROXY_VERSION="12.2.3"` in `cli/lib/constants.bash`. Consumed by the template placeholder replacement and `apply_secret_provider`. +- **Build side** (CI workflows + Dockerfiles): `images/mitmproxy.env` with `MITMPROXY_VERSION=12.2.3`. Consumed by both image workflows as a `--build-arg`; both Dockerfiles take `ARG MITMPROXY_VERSION` (no default — same "no defaults on purpose" convention as the existing `pass-cli.env`). + +A **contract bats test** asserts the two values are equal, so a bump that touches only one side fails CI. Bump = edit 2 lines in 2 files. + +## Mechanics + +### CLI side + +- Template `cli/templates/devcontainer/sandcat/compose-proxy.yml`: `image: mitmproxy/mitmproxy:latest` → `image: mitmproxy/mitmproxy:__MITMPROXY_VERSION__`. +- `customize_agent_templates` (cli/lib/devcontainer.bash) already runs `apply_inline_placeholders` on compose-proxy.yml with `__MITM_HTTP2__` etc. — add `"__MITMPROXY_VERSION__" "$SCT_MITMPROXY_VERSION"` to that call. +- `apply_secret_provider` (cli/lib/composefile.bash): `ghcr.io/virtuslab/sandcat-mitmproxy-op:latest` → `...:$SCT_MITMPROXY_VERSION` (yq `env()` injection, matching the file's existing style); same for `-pass`. + +### Build side + +- `images/mitmproxy/Dockerfile` and `images/mitmproxy-pass/Dockerfile`: `ARG MITMPROXY_VERSION` + `FROM mitmproxy/mitmproxy:${MITMPROXY_VERSION}`. +- Both workflows (`build-mitmproxy-image.yml`, `build-mitmproxy-pass-image.yml`): + - **push to master** (bump commit; `paths` trigger already covers `images/**`): read `images/mitmproxy.env`, pass as build-arg, tag with the version + sha. Do NOT move `latest`. + - **schedule (weekly cron)**: pass `MITMPROXY_VERSION=latest` as build-arg, tag ONLY `latest`. + - **pull_request**: build with the pinned version, no push (keep current PR behavior). +- Workflows also gain `paths: images/mitmproxy.env` in triggers so the bump commit rebuilds both images even when only the env file changes. + +### rtk + +`cli/lib/rtk.bash` install block: pin the install script to a specific commit SHA (`raw.githubusercontent.com/rtk-ai/rtk//install.sh`). Honest limitation, documented in the comment: rtk publishes only `dev-*` pre-releases today, and its install.sh fetches a binary release internally — the SHA pin freezes the *script* (closing the arbitrary-shell-injection-via-master vector) but not necessarily the *binary*. Full binary pinning is deferred until rtk ships stable releases. + +## Effective flow after the change + +``` +Bump commit (2 lines: constants.bash + images/mitmproxy.env) + │ push to master, paths trigger + ▼ +CI builds FROM mitmproxy:12.3.0 → publishes sandcat-mitmproxy-op:12.3.0 (+sha) + │ + ▼ +Same commit's CLI generates projects referencing: + - mitmproxy/mitmproxy:12.3.0 (provider none) + - ghcr.io/.../sandcat-mitmproxy-op:12.3.0 (provider 1password) + +Weekly cron: builds FROM mitmproxy:latest → pushes ONLY ghcr :latest (tracking channel, untouched by projects) +``` + +## Out of scope (deferred, with rationale) + +- **Base images** (`mcr.microsoft.com/devcontainers/base:debian`, `debian:trixie-slim`) — agent-side, inside the sandbox boundary; lower criticality. Follow-up. +- **Agent CLI installers** (claude.ai, chatgpt.com, cursor.com) — vendor-official scripts for tools that self-update at runtime; a pin buys little. +- **devbox installer** — same category. +- **Phase 2**: Renovate automation (regex managers for the bash-heredoc Dockerfile fragments). **Phase 3**: digest pinning. + +## Testing + +- Contract bats test: `SCT_MITMPROXY_VERSION` == `images/mitmproxy.env` value; template placeholder resolves (no `__MITMPROXY_VERSION__` left in generated compose-proxy.yml). +- Existing `regression.bats` `assert_proxy_service` asserts `image == "mitmproxy/mitmproxy:latest"` — update to the pinned reference (this is a deliberate assertion change mandated by the feature, not a weakening). +- Integration: generated project (provider none) pulls and runs the pinned public image end-to-end; provider 1password renders the versioned ghcr tag (pull not asserted — the tag publishes only after this lands on master; noted in report). + +## Global Constraints + +- Pinned version at introduction: **12.2.3** (verified current `mitmproxy --version` of upstream latest). +- CLI constant: `SCT_MITMPROXY_VERSION` in `cli/lib/constants.bash`. +- Build env file: `images/mitmproxy.env`, key `MITMPROXY_VERSION`. +- Template placeholder literal: `__MITMPROXY_VERSION__`. +- Dockerfiles: `ARG MITMPROXY_VERSION` with **no default**. +- Cron semantics: schedule builds use `MITMPROXY_VERSION=latest` and push only the `latest` tag; version tags come only from master pushes. +- rtk: script pinned by commit SHA; comment documents the binary-pin limitation. +- No behavior change for generated projects beyond the image references. From ad9be6850eb0824ad68d2fe425adb6ac91054d47 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Mon, 17 Aug 2026 15:22:36 +0200 Subject: [PATCH 02/13] security(cli): pin mitmproxy image references to SCT_MITMPROXY_VERSION (#20) Introduces SCT_MITMPROXY_VERSION="12.2.3" in constants.bash and threads it through the compose-proxy.yml template placeholder and apply_secret_provider's 1password/protonpass ghcr images, replacing all `:latest` tags on the CLI side. devcontainer.bash now sources constants.bash directly (it wasn't transitively sourced through its own dependency chain, only accidentally via caller ordering in production). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ --- cli/lib/composefile.bash | 8 ++++---- cli/lib/constants.bash | 5 +++++ cli/lib/devcontainer.bash | 5 ++++- .../devcontainer/sandcat/compose-proxy.yml | 2 +- cli/test/composefile/composefile.bats | 4 ++-- cli/test/init/extensions.bats | 18 ++++++++++++++++++ cli/test/init/regression.bats | 2 +- 7 files changed, 35 insertions(+), 9 deletions(-) diff --git a/cli/lib/composefile.bash b/cli/lib/composefile.bash index 74283f6d..c510ea6f 100644 --- a/cli/lib/composefile.bash +++ b/cli/lib/composefile.bash @@ -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" ;; diff --git a/cli/lib/constants.bash b/cli/lib/constants.bash index 4d47ca8d..731c3f9f 100644 --- a/cli/lib/constants.bash +++ b/cli/lib/constants.bash @@ -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" diff --git a/cli/lib/devcontainer.bash b/cli/lib/devcontainer.bash index 5145d965..90b873d9 100644 --- a/cli/lib/devcontainer.bash +++ b/cli/lib/devcontainer.bash @@ -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 @@ -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" } diff --git a/cli/templates/devcontainer/sandcat/compose-proxy.yml b/cli/templates/devcontainer/sandcat/compose-proxy.yml index 7d3be2c4..775bfcf6 100644 --- a/cli/templates/devcontainer/sandcat/compose-proxy.yml +++ b/cli/templates/devcontainer/sandcat/compose-proxy.yml @@ -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 diff --git a/cli/test/composefile/composefile.bats b/cli/test/composefile/composefile.bats index e1ac4e59..a34dacf6 100644 --- a/cli/test/composefile/composefile.bats +++ b/cli/test/composefile/composefile.bats @@ -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" } @@ -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" } diff --git a/cli/test/init/extensions.bats b/cli/test/init/extensions.bats index 25e2e9fe..014c3a2e 100644 --- a/cli/test/init/extensions.bats +++ b/cli/test/init/extensions.bats @@ -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: []' diff --git a/cli/test/init/regression.bats b/cli/test/init/regression.bats index adea6532..f57f0168 100644 --- a/cli/test/init/regression.bats +++ b/cli/test/init/regression.bats @@ -22,7 +22,7 @@ teardown() { assert_proxy_service() { local compose_file=$1 - yq -e '.services.mitmproxy.image == "mitmproxy/mitmproxy:latest"' "$compose_file" + yq -e '.services.mitmproxy.image == "mitmproxy/mitmproxy:'"$SCT_MITMPROXY_VERSION"'"' "$compose_file" # FIXME vscode startup fails with capabilities dropped # yq -e '.services.mitmproxy.cap_drop[] | select(. == "ALL")' "$compose_file" From b200b6650d54a41f69fe9c3af7cbf1c86eff6c1d Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Mon, 17 Aug 2026 16:24:11 +0200 Subject: [PATCH 03/13] security(images): pin mitmproxy base via images/mitmproxy.env; cron rebuilds latest only (#20) Adds images/mitmproxy.env as the single source of truth for the pinned mitmproxy base image version, consumed by both build workflows and both Dockerfiles, and asserted equal to the CLI-side SCT_MITMPROXY_VERSION by a new contract test. Both Dockerfiles take MITMPROXY_VERSION as a build arg with no default (pin lives in exactly one place) and parameterize their FROM. Both workflows load the pin into $GITHUB_ENV for all non-scheduled triggers, override it to `latest` for the weekly schedule, and reshape their metadata-action tags into three buckets: schedule publishes only `latest` (tracking channel), push/workflow_dispatch publish the pinned version + sha as immutable tags, and pull_request keeps its existing ref tag with no push (unchanged behavior). --- .github/workflows/build-mitmproxy-image.yml | 30 ++++++++++-- .../workflows/build-mitmproxy-pass-image.yml | 29 ++++++++++-- cli/test/compat/mitmproxy_version.bats | 46 +++++++++++++++++++ images/mitmproxy-pass/Dockerfile | 13 +++++- images/mitmproxy.env | 23 ++++++++++ images/mitmproxy/Dockerfile | 13 +++++- 6 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 cli/test/compat/mitmproxy_version.bats create mode 100644 images/mitmproxy.env diff --git a/.github/workflows/build-mitmproxy-image.yml b/.github/workflows/build-mitmproxy-image.yml index 841f9f7f..4ee7f144 100644 --- a/.github/workflows/build-mitmproxy-image.yml +++ b/.github/workflows/build-mitmproxy-image.yml @@ -5,10 +5,12 @@ 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 @@ -35,16 +37,34 @@ 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 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV" + + - name: Use latest mitmproxy for the scheduled tracking build + # Scheduled builds intentionally bypass the pin to track upstream + # mitmproxy:latest; they publish only the `latest` tag, never a + # version tag, so this can't be mistaken for the pinned image. + 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: schedule builds publish only `latest` (tracking + # channel). Everything else that isn't a PR (push to master, + # workflow_dispatch) publishes the pinned version + sha as + # immutable tags. PRs keep their existing ref tag and are never + # pushed (see the `push:` condition below). 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 == 'schedule' }} + type=raw,value=${{ env.MITMPROXY_VERSION }},enable=${{ github.event_name != 'schedule' && github.event_name != 'pull_request' }} + type=sha,enable=${{ github.event_name != 'schedule' && github.event_name != 'pull_request' }} + type=ref,event=pr,enable=${{ github.event_name == 'pull_request' }} - name: Build and push Docker image uses: docker/build-push-action@v5 @@ -55,6 +75,8 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + MITMPROXY_VERSION=${{ env.MITMPROXY_VERSION }} # Skip cache on scheduled builds to pick up upstream mitmproxy:latest changes cache-from: ${{ github.event_name != 'schedule' && 'type=gha' || '' }} cache-to: type=gha,mode=max diff --git a/.github/workflows/build-mitmproxy-pass-image.yml b/.github/workflows/build-mitmproxy-pass-image.yml index 3a6b1ba5..1cbf8f2a 100644 --- a/.github/workflows/build-mitmproxy-pass-image.yml +++ b/.github/workflows/build-mitmproxy-pass-image.yml @@ -5,10 +5,12 @@ 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 @@ -40,16 +42,34 @@ 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 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV" + + - name: Use latest mitmproxy for the scheduled tracking build + # Scheduled builds intentionally bypass the pin to track upstream + # mitmproxy:latest; they publish only the `latest` tag, never a + # version tag, so this can't be mistaken for the pinned image. + 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: schedule builds publish only `latest` (tracking + # channel). Everything else that isn't a PR (push to master, + # workflow_dispatch) publishes the pinned version + sha as + # immutable tags. PRs keep their existing ref tag and are never + # pushed (see the `push:` condition below). 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 == 'schedule' }} + type=raw,value=${{ env.MITMPROXY_VERSION }},enable=${{ github.event_name != 'schedule' && github.event_name != 'pull_request' }} + type=sha,enable=${{ github.event_name != 'schedule' && github.event_name != 'pull_request' }} + type=ref,event=pr,enable=${{ github.event_name == 'pull_request' }} - name: Build and push Docker image uses: docker/build-push-action@v5 @@ -61,6 +81,7 @@ jobs: 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 }} diff --git a/cli/test/compat/mitmproxy_version.bats b/cli/test/compat/mitmproxy_version.bats new file mode 100644 index 00000000..e50799f6 --- /dev/null +++ b/cli/test/compat/mitmproxy_version.bats @@ -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 +} diff --git a/images/mitmproxy-pass/Dockerfile b/images/mitmproxy-pass/Dockerfile index 4cbee306..533e53a5 100644 --- a/images/mitmproxy-pass/Dockerfile +++ b/images/mitmproxy-pass/Dockerfile @@ -13,9 +13,18 @@ # pass-cli pat access grant --pat-name "sandcat" --vault-name "MyVault" --role viewer # # Published as ghcr.io/virtuslab/sandcat-mitmproxy-pass. -# Rebuilt weekly to track mitmproxy:latest. +# +# Dual-channel tag policy: versioned tags (e.g. 12.2.3) are built from master +# pushes against the pin in images/mitmproxy.env and are immutable. The +# `latest` tag is rebuilt weekly from upstream mitmproxy:latest as a +# best-effort tracking channel — it is NOT what pinned installs resolve to. +# +# MITMPROXY_VERSION has no default on purpose: the pin lives in exactly one +# place (images/mitmproxy.env) and MUST be supplied as a build arg (the build +# workflow sources that file; see images/mitmproxy.env for all consumers). +ARG MITMPROXY_VERSION -FROM mitmproxy/mitmproxy:latest +FROM mitmproxy/mitmproxy:${MITMPROXY_VERSION} # Version + per-arch checksums are the single source of truth in # images/mitmproxy-pass/pass-cli.env and MUST be supplied as build args diff --git a/images/mitmproxy.env b/images/mitmproxy.env new file mode 100644 index 00000000..6f139e01 --- /dev/null +++ b/images/mitmproxy.env @@ -0,0 +1,23 @@ +# Single source of truth for the pinned mitmproxy base image version. +# +# Consumed by: +# - .github/workflows/build-mitmproxy-image.yml (loaded, then passed +# in as --build-arg for non-scheduled builds) +# - .github/workflows/build-mitmproxy-pass-image.yml (same) +# - images/mitmproxy/Dockerfile (ARG MITMPROXY_VERSION) +# - images/mitmproxy-pass/Dockerfile (ARG MITMPROXY_VERSION) +# - cli/test/compat/mitmproxy_version.bats (contract test) +# +# Must stay equal to SCT_MITMPROXY_VERSION in cli/lib/constants.bash — the +# CLI-side counterpart used to render pinned image refs into generated +# compose files. The contract test above asserts the two match. +# +# Weekly scheduled builds bypass this pin on purpose: they build +# MITMPROXY_VERSION=latest to track upstream mitmproxy:latest as a +# tracking channel, publishing only the `latest` tag. Master pushes build +# this pinned version and publish it as an immutable versioned tag. +# +# Format note: simple KEY=value lines only (no quotes, no spaces around `=`) +# so this file is consumable by `source`, GitHub Actions $GITHUB_ENV, and the +# minimal parser in the contract test alike. +MITMPROXY_VERSION=12.2.3 diff --git a/images/mitmproxy/Dockerfile b/images/mitmproxy/Dockerfile index ae041151..c219eae3 100644 --- a/images/mitmproxy/Dockerfile +++ b/images/mitmproxy/Dockerfile @@ -6,9 +6,18 @@ # `op read`, authenticated with OP_SERVICE_ACCOUNT_TOKEN. # # Published as ghcr.io/virtuslab/sandcat-mitmproxy-op. -# Rebuilt weekly to track mitmproxy:latest. +# +# Dual-channel tag policy: versioned tags (e.g. 12.2.3) are built from master +# pushes against the pin in images/mitmproxy.env and are immutable. The +# `latest` tag is rebuilt weekly from upstream mitmproxy:latest as a +# best-effort tracking channel — it is NOT what pinned installs resolve to. +# +# MITMPROXY_VERSION has no default on purpose: the pin lives in exactly one +# place (images/mitmproxy.env) and MUST be supplied as a build arg (the build +# workflow sources that file; see images/mitmproxy.env for all consumers). +ARG MITMPROXY_VERSION -FROM mitmproxy/mitmproxy:latest +FROM mitmproxy/mitmproxy:${MITMPROXY_VERSION} ARG OP_CLI_VERSION=2.30.3 From 009fb9a75a043d83087f68745195492fce644962 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Mon, 17 Aug 2026 16:50:53 +0200 Subject: [PATCH 04/13] fix(ci): restrict version tags to master refs in image workflows The pinned tag was reachable from workflow_dispatch on any ref, so a manual dispatch off a feature branch could overwrite the canonical immutable tag with unmerged code. Gate it to push (already master-only) or workflow_dispatch explicitly on refs/heads/master; off-master dispatch now falls back to a branch-name + sha tag instead, so manual runs are never tagless and never collide with the canonical tag. --- .github/workflows/build-mitmproxy-image.yml | 14 +++++++++----- .github/workflows/build-mitmproxy-pass-image.yml | 14 +++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-mitmproxy-image.yml b/.github/workflows/build-mitmproxy-image.yml index 4ee7f144..1310fd72 100644 --- a/.github/workflows/build-mitmproxy-image.yml +++ b/.github/workflows/build-mitmproxy-image.yml @@ -56,14 +56,18 @@ jobs: with: images: ghcr.io/virtuslab/sandcat-mitmproxy-op # Tag policy: schedule builds publish only `latest` (tracking - # channel). Everything else that isn't a PR (push to master, - # workflow_dispatch) publishes the pinned version + sha as - # immutable tags. PRs keep their existing ref tag and are never - # pushed (see the `push:` condition below). + # channel). The pinned tag is immutable and can only be + # published from master — a push (hard-restricted to master by the + # trigger above) or a workflow_dispatch run explicitly on master — + # so a manual dispatch from a feature branch can never overwrite + # it. Off-master dispatch still gets a sha + branch-name tag (never + # tagless, never colliding with the canonical tag). PRs keep their + # existing ref tag and are never pushed (see `push:` below). tags: | type=raw,value=latest,enable=${{ github.event_name == 'schedule' }} - type=raw,value=${{ env.MITMPROXY_VERSION }},enable=${{ github.event_name != 'schedule' && github.event_name != 'pull_request' }} + 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 diff --git a/.github/workflows/build-mitmproxy-pass-image.yml b/.github/workflows/build-mitmproxy-pass-image.yml index 1cbf8f2a..ceee064f 100644 --- a/.github/workflows/build-mitmproxy-pass-image.yml +++ b/.github/workflows/build-mitmproxy-pass-image.yml @@ -61,14 +61,18 @@ jobs: with: images: ghcr.io/virtuslab/sandcat-mitmproxy-pass # Tag policy: schedule builds publish only `latest` (tracking - # channel). Everything else that isn't a PR (push to master, - # workflow_dispatch) publishes the pinned version + sha as - # immutable tags. PRs keep their existing ref tag and are never - # pushed (see the `push:` condition below). + # channel). The pinned tag is immutable and can only be + # published from master — a push (hard-restricted to master by the + # trigger above) or a workflow_dispatch run explicitly on master — + # so a manual dispatch from a feature branch can never overwrite + # it. Off-master dispatch still gets a sha + branch-name tag (never + # tagless, never colliding with the canonical tag). PRs keep their + # existing ref tag and are never pushed (see `push:` below). tags: | type=raw,value=latest,enable=${{ github.event_name == 'schedule' }} - type=raw,value=${{ env.MITMPROXY_VERSION }},enable=${{ github.event_name != 'schedule' && github.event_name != 'pull_request' }} + 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 From e059913dacbdbd0e40abfc786f810c7f3d9f499c Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Mon, 17 Aug 2026 16:55:26 +0200 Subject: [PATCH 05/13] security(rtk): pin install script to commit SHA (#20) Pin the rtk install.sh download URL to a specific commit SHA rather than the master branch, closing the arbitrary-code-via-master attack vector. The script itself downloads a binary internally, so the final binary is not fully pinned until rtk publishes stable releases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ --- cli/lib/rtk.bash | 8 +++++++- cli/test/rtk/rtk.bats | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cli/lib/rtk.bash b/cli/lib/rtk.bash index cea90361..0943f536 100644 --- a/cli/lib/rtk.bash +++ b/cli/lib/rtk.bash @@ -16,6 +16,12 @@ 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. Note that +# rtk's install.sh itself downloads a binary release internally, so the +# binary is not fully pinned until rtk publishes stable releases (today +# it has only dev-* pre-releases). Revisit when stable releases exist. +# # Emits an empty output when the feature is disabled so the caller can # unconditionally append it to Dockerfile fragments. sct_rtk_docker_install_block() { @@ -26,7 +32,7 @@ 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 sh USER vscode EOF } diff --git a/cli/test/rtk/rtk.bats b/cli/test/rtk/rtk.bats index e61c6423..c63a46cb 100644 --- a/cli/test/rtk/rtk.bats +++ b/cli/test/rtk/rtk.bats @@ -41,6 +41,9 @@ setup() { assert_output --partial "RTK_INSTALL_DIR=/usr/local/bin" assert_output --partial "USER root" assert_output --partial "USER vscode" + # Verify the URL is pinned to a 40-character hex commit SHA (not master branch) + # Uses grep to check for the SHA pattern: rtk-ai/rtk/[40-hex-chars]/install.sh + grep -qE "rtk-ai/rtk/[0-9a-f]{40}/install.sh" <<<"$output" } @test "sct_rtk_docker_install_block emits nothing when disabled" { From 6cb663ae5afbabca69fc88478a1f91a2089b3e5a Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Mon, 17 Aug 2026 16:59:48 +0200 Subject: [PATCH 06/13] docs: document pinned mitmproxy version + bump procedure Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ --- cli/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cli/README.md b/cli/README.md index 1489f95d..294b42cb 100644 --- a/cli/README.md +++ b/cli/README.md @@ -94,6 +94,23 @@ 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. +##### 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 new versioned `ghcr.io/virtuslab/sandcat-mitmproxy` tags +5. The weekly cron job rebuilds only the `latest` tag from upstream mitmproxy and never touches versioned tags + +Generated projects reference the pinned version from the CLI-side constant, so projects created with `sandcat init` always use the stable versioned image. + 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 From 01ccca6525b9f2c0c933af66d2d85885d53da3c9 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Tue, 18 Aug 2026 12:10:38 +0200 Subject: [PATCH 07/13] fix(ci): run cli-tests on images/mitmproxy.env + grep -m1 in image workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mitmproxy_version.bats contract test is the only sync enforcement between cli/lib/constants.bash and images/mitmproxy.env, but cli-tests.yml only triggered on cli/** — a bump touching only the env file never ran the test, so image workflows could go green while the CLI-side constant silently drifted out of sync. Also add -m1 to both image workflows' grep of MITMPROXY_VERSION so a duplicate key in the env file can't make the workflow (last-wins) and the contract test (first-wins, -m1) resolve to different values. Fixes #20 --- .github/workflows/build-mitmproxy-image.yml | 2 +- .github/workflows/build-mitmproxy-pass-image.yml | 2 +- .github/workflows/cli-tests.yml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-mitmproxy-image.yml b/.github/workflows/build-mitmproxy-image.yml index 1310fd72..4c9de9b8 100644 --- a/.github/workflows/build-mitmproxy-image.yml +++ b/.github/workflows/build-mitmproxy-image.yml @@ -41,7 +41,7 @@ jobs: # 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 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV" + run: grep -m1 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV" - name: Use latest mitmproxy for the scheduled tracking build # Scheduled builds intentionally bypass the pin to track upstream diff --git a/.github/workflows/build-mitmproxy-pass-image.yml b/.github/workflows/build-mitmproxy-pass-image.yml index ceee064f..a6537cfa 100644 --- a/.github/workflows/build-mitmproxy-pass-image.yml +++ b/.github/workflows/build-mitmproxy-pass-image.yml @@ -46,7 +46,7 @@ jobs: # 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 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV" + run: grep -m1 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV" - name: Use latest mitmproxy for the scheduled tracking build # Scheduled builds intentionally bypass the pin to track upstream diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 7326b664..dd37aca9 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -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 From 874ccf814cf7d46700f1d5295b7a0adcce995244 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Tue, 18 Aug 2026 12:10:45 +0200 Subject: [PATCH 08/13] security(rtk): pin binary via RTK_VERSION; correct stale-premise comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed rtk "has only dev-* pre-releases" so the binary couldn't be pinned — verified false: `gh api repos/rtk-ai/rtk/releases/latest` returns v0.45.0 with prerelease: false, and that release's target commit is the same b34be37 SHA already pinned for install.sh. rtk's install.sh also supports RTK_VERSION=vX.Y.Z pinning and SHA-256-verifies the downloaded archive against the release's checksums.txt. Add RTK_VERSION=v0.45.0 to the install invocation so both the script (commit SHA) and the binary it fetches (checksum-verified release) are pinned. Extend rtk.bats to assert the emitted block contains RTK_VERSION=v so the binary pin can't silently regress. Also update the README's host-side rtk install instructions to use the same pinned commit + RTK_VERSION instead of pointing at master. Fixes #20 --- README.md | 2 +- cli/lib/rtk.bash | 12 +++++++----- cli/test/rtk/rtk.bats | 4 ++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 847266b8..557f142e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/lib/rtk.bash b/cli/lib/rtk.bash index 0943f536..56918dc9 100644 --- a/cli/lib/rtk.bash +++ b/cli/lib/rtk.bash @@ -17,10 +17,11 @@ sct_rtk_enabled() { # 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. Note that -# rtk's install.sh itself downloads a binary release internally, so the -# binary is not fully pinned until rtk publishes stable releases (today -# it has only dev-* pre-releases). Revisit when stable releases exist. +# 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. @@ -32,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/b34be37caf3796b69a50952a28e60e32b5daad43/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 } diff --git a/cli/test/rtk/rtk.bats b/cli/test/rtk/rtk.bats index c63a46cb..e9e6e50e 100644 --- a/cli/test/rtk/rtk.bats +++ b/cli/test/rtk/rtk.bats @@ -44,6 +44,10 @@ setup() { # Verify the URL is pinned to a 40-character hex commit SHA (not master branch) # Uses grep to check for the SHA pattern: rtk-ai/rtk/[40-hex-chars]/install.sh grep -qE "rtk-ai/rtk/[0-9a-f]{40}/install.sh" <<<"$output" + # Guard against dropping the binary pin: RTK_VERSION must be set so + # install.sh fetches (and checksum-verifies) a pinned release, not + # whatever is currently latest. + assert_output --partial "RTK_VERSION=v" } @test "sct_rtk_docker_install_block emits nothing when disabled" { From 72ea6688360f636a68917246637efd3fa2065699 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Tue, 18 Aug 2026 12:10:48 +0200 Subject: [PATCH 09/13] docs: bump-procedure race note, image names, immutability wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli/README.md's bump procedure was missing the merge-day race: between merging a version bump and the image workflows publishing the new versioned ghcr tags, `docker compose up` fails loudly with `manifest unknown` for the op/pass providers (provider none is unaffected — it resolves straight to the public mitmproxy/mitmproxy Docker Hub tag). Document waiting for both workflows before standing up or upgrading a secret-provider project. Also fix the bump procedure's image name, which referenced a nonexistent `sandcat-mitmproxy` — the published images are `sandcat-mitmproxy-op` and `sandcat-mitmproxy-pass`. Soften "immutable" to reflect that it's a convention, not a guarantee: a dispatch on master can re-push the same versioned tag, and registry tags aren't cryptographically frozen without digest pinning. Fixes #20 --- cli/README.md | 4 +++- images/mitmproxy-pass/Dockerfile | 8 +++++--- images/mitmproxy.env | 4 +++- images/mitmproxy/Dockerfile | 8 +++++--- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/cli/README.md b/cli/README.md index 294b42cb..297dd62f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -106,11 +106,13 @@ 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 new versioned `ghcr.io/virtuslab/sandcat-mitmproxy` tags +4. Merge to master — the image build workflows are triggered by changes to `images/mitmproxy.env` and publish new versioned `ghcr.io/virtuslab/sandcat-mitmproxy-op` and `ghcr.io/virtuslab/sandcat-mitmproxy-pass` tags 5. The weekly cron job rebuilds only the `latest` tag from upstream mitmproxy and never touches versioned tags 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. + 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 diff --git a/images/mitmproxy-pass/Dockerfile b/images/mitmproxy-pass/Dockerfile index 533e53a5..efcd65a1 100644 --- a/images/mitmproxy-pass/Dockerfile +++ b/images/mitmproxy-pass/Dockerfile @@ -15,9 +15,11 @@ # Published as ghcr.io/virtuslab/sandcat-mitmproxy-pass. # # Dual-channel tag policy: versioned tags (e.g. 12.2.3) are built from master -# pushes against the pin in images/mitmproxy.env and are immutable. The -# `latest` tag is rebuilt weekly from upstream mitmproxy:latest as a -# best-effort tracking channel — it is NOT what pinned installs resolve to. +# pushes against the pin in images/mitmproxy.env; they're never overwritten +# by the weekly cron and are treated as immutable by convention (digest +# pinning is a possible future hardening). The `latest` tag is rebuilt +# weekly from upstream mitmproxy:latest as a best-effort tracking channel — +# it is NOT what pinned installs resolve to. # # MITMPROXY_VERSION has no default on purpose: the pin lives in exactly one # place (images/mitmproxy.env) and MUST be supplied as a build arg (the build diff --git a/images/mitmproxy.env b/images/mitmproxy.env index 6f139e01..ecfa2e20 100644 --- a/images/mitmproxy.env +++ b/images/mitmproxy.env @@ -15,7 +15,9 @@ # Weekly scheduled builds bypass this pin on purpose: they build # MITMPROXY_VERSION=latest to track upstream mitmproxy:latest as a # tracking channel, publishing only the `latest` tag. Master pushes build -# this pinned version and publish it as an immutable versioned tag. +# this pinned version and publish it as a versioned tag that is never +# overwritten by the weekly cron and treated as immutable by convention +# (digest pinning is a possible future hardening). # # Format note: simple KEY=value lines only (no quotes, no spaces around `=`) # so this file is consumable by `source`, GitHub Actions $GITHUB_ENV, and the diff --git a/images/mitmproxy/Dockerfile b/images/mitmproxy/Dockerfile index c219eae3..0b842b4c 100644 --- a/images/mitmproxy/Dockerfile +++ b/images/mitmproxy/Dockerfile @@ -8,9 +8,11 @@ # Published as ghcr.io/virtuslab/sandcat-mitmproxy-op. # # Dual-channel tag policy: versioned tags (e.g. 12.2.3) are built from master -# pushes against the pin in images/mitmproxy.env and are immutable. The -# `latest` tag is rebuilt weekly from upstream mitmproxy:latest as a -# best-effort tracking channel — it is NOT what pinned installs resolve to. +# pushes against the pin in images/mitmproxy.env; they're never overwritten +# by the weekly cron and are treated as immutable by convention (digest +# pinning is a possible future hardening). The `latest` tag is rebuilt +# weekly from upstream mitmproxy:latest as a best-effort tracking channel — +# it is NOT what pinned installs resolve to. # # MITMPROXY_VERSION has no default on purpose: the pin lives in exactly one # place (images/mitmproxy.env) and MUST be supplied as a build arg (the build From 914196b9042797d994db8ee3aaee31b13ca09b81 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Tue, 18 Aug 2026 12:10:53 +0200 Subject: [PATCH 10/13] chore: remove SDD spec + plan artifacts from branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are working documents from the spec-driven-development process, not project documentation — drop them before merge. --- .../plans/2026-08-17-pin-image-versions.md | 118 ------------------ .../2026-08-17-pin-image-versions-design.md | 89 ------------- 2 files changed, 207 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-17-pin-image-versions.md delete mode 100644 docs/superpowers/specs/2026-08-17-pin-image-versions-design.md diff --git a/docs/superpowers/plans/2026-08-17-pin-image-versions.md b/docs/superpowers/plans/2026-08-17-pin-image-versions.md deleted file mode 100644 index 5583afea..00000000 --- a/docs/superpowers/plans/2026-08-17-pin-image-versions.md +++ /dev/null @@ -1,118 +0,0 @@ -# Pin Image Versions (issue #20, Phase 1) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Pin the mitmproxy image chain (public image in generated projects, both derived ghcr secret-provider images) to an exact version with a two-file source of truth enforced by a contract test; make the weekly cron rebuild only the `latest` tracking channel; freeze the rtk install script to a commit SHA. - -**Architecture:** CLI side gets `SCT_MITMPROXY_VERSION` in `cli/lib/constants.bash`, consumed by a new `__MITMPROXY_VERSION__` placeholder in the compose-proxy template and by `apply_secret_provider`. Build side gets `images/mitmproxy.env`, consumed by both image workflows as `--build-arg` into `ARG MITMPROXY_VERSION` (no default) in both Dockerfiles. A contract bats test keeps both values equal. Cron passes `MITMPROXY_VERSION=latest` and tags only `latest`; master pushes tag the version. - -**Tech Stack:** bash + yq, bats, GitHub Actions, Dockerfiles. - -## Global Constraints - -- Pinned version at introduction: **12.2.3**. -- CLI constant: `SCT_MITMPROXY_VERSION="12.2.3"` in `cli/lib/constants.bash`. -- Build env file: `images/mitmproxy.env` with `MITMPROXY_VERSION=12.2.3` (comment header explaining it's the build-side source of truth, mirroring `images/mitmproxy-pass/pass-cli.env` style). -- Template placeholder: `__MITMPROXY_VERSION__` in `cli/templates/devcontainer/sandcat/compose-proxy.yml`, replaced in `customize_agent_templates`'s existing `apply_inline_placeholders` call. -- `apply_secret_provider` writes `ghcr.io/virtuslab/sandcat-mitmproxy-op:` / `-pass:` using `$SCT_MITMPROXY_VERSION`. -- Both `images/*/Dockerfile`: `ARG MITMPROXY_VERSION` (no default) + `FROM mitmproxy/mitmproxy:${MITMPROXY_VERSION}`. -- Workflows: master push → build with pinned version, tags = version + sha (NOT latest); schedule → build with `MITMPROXY_VERSION=latest`, tag = latest only; PR → build pinned, no push (preserve current PR push behavior). -- rtk: `raw.githubusercontent.com/rtk-ai/rtk//install.sh` with a comment documenting the script-vs-binary pin limitation. -- `regression.bats` `assert_proxy_service` updated from `mitmproxy/mitmproxy:latest` to the pinned reference — a mandated assertion change, not a weakening. -- All existing bats suites green. - ---- - -### Task 1: CLI-side pinning - -**Files:** -- Modify: `cli/lib/constants.bash` (add `SCT_MITMPROXY_VERSION="12.2.3"` with a comment pointing at `images/mitmproxy.env` + the contract test) -- Modify: `cli/templates/devcontainer/sandcat/compose-proxy.yml` (`image: mitmproxy/mitmproxy:latest` → `image: mitmproxy/mitmproxy:__MITMPROXY_VERSION__`) -- Modify: `cli/lib/devcontainer.bash` (add the placeholder pair to the existing `apply_inline_placeholders` call that already handles `__MITM_HTTP2__` — around line 283-286) -- Modify: `cli/lib/composefile.bash` (`apply_secret_provider`: both ghcr images get `:` + `$SCT_MITMPROXY_VERSION`; use `env()` injection in yq, matching file style) -- Test: `cli/test/init/regression.bats` (`assert_proxy_service` — pinned image reference), `cli/test/init/extensions.bats` (add: generated compose-proxy.yml contains `mitmproxy/mitmproxy:12.2.3` and no unresolved `__MITMPROXY_VERSION__`), `cli/test/composefile/composefile.bats` (secret-provider tests if they assert the `:latest` ghcr tags — re-point to versioned) - -**Interfaces:** -- Consumes: existing placeholder-replacement flow. -- Produces: `SCT_MITMPROXY_VERSION` (Task 2's contract test reads it; Task 4's docs reference it). - -**Steps:** (implementer works test-first per file group) - -- [ ] **Step 1**: Grep current assertions: `grep -rn "mitmproxy/mitmproxy:latest\|sandcat-mitmproxy-op:latest\|sandcat-mitmproxy-pass:latest" cli/` — every hit is either a change site or a test to update. List them in the report. -- [ ] **Step 2**: Add the constant to `constants.bash`; template placeholder; wire `apply_inline_placeholders`; update `apply_secret_provider`. -- [ ] **Step 3**: Update/extend the tests found in Step 1. New extensions.bats test asserts BOTH: resolved pinned image AND `run grep '__MITMPROXY_VERSION__' ...; assert_failure`. -- [ ] **Step 4**: `cd cli && ./run-tests.bash test/init/ test/composefile/` green; then full surface. -- [ ] **Step 5**: Commit: `security(cli): pin mitmproxy image references to SCT_MITMPROXY_VERSION (#20)` - ---- - -### Task 2: Build-side pinning + workflow tag policy - -**Files:** -- Create: `images/mitmproxy.env` (`MITMPROXY_VERSION=12.2.3` + header comment listing consumers) -- Modify: `images/mitmproxy/Dockerfile`, `images/mitmproxy-pass/Dockerfile` (`ARG MITMPROXY_VERSION` no default + parameterized FROM; update the header comment that says "Rebuilt weekly to track mitmproxy:latest" to describe the new dual-channel policy) -- Modify: `.github/workflows/build-mitmproxy-image.yml`, `.github/workflows/build-mitmproxy-pass-image.yml`: - - add `images/mitmproxy.env` to both `paths` trigger lists - - a step that reads the env file into `$GITHUB_ENV` (skip on schedule) - - build-arg: `MITMPROXY_VERSION=${{ github.event_name == 'schedule' && 'latest' || env.MITMPROXY_VERSION }}` (or equivalent two-step logic — implementer picks the cleanest correct form) - - metadata-action tags: replace the current list with conditional logic — schedule → `latest` only; master push → `` + `sha`; PR → current PR-tag behavior, no push. Verify with `docker/metadata-action` `enable=` expressions or split into two metadata steps guarded by `if:`. -- Test: new contract test `cli/test/compat/mitmproxy_version.bats` — parses `SCT_MITMPROXY_VERSION` from `cli/lib/constants.bash` and `MITMPROXY_VERSION` from `images/mitmproxy.env`, asserts equal. Also asserts both Dockerfiles contain `ARG MITMPROXY_VERSION` and the parameterized FROM (guards against a stray re-hardcode). - -**Interfaces:** -- Consumes: `SCT_MITMPROXY_VERSION` from Task 1. -- Produces: `images/mitmproxy.env` (Task 4 documents the bump procedure around it). - -**Steps:** - -- [ ] **Step 1**: Write the contract test first; run — fails (env file missing). -- [ ] **Step 2**: Create env file, patch both Dockerfiles. -- [ ] **Step 3**: Patch both workflows. Validate YAML: `yq . .github/workflows/build-mitmproxy-image.yml >/dev/null` (and actionlint if available — check `command -v actionlint`). -- [ ] **Step 4**: Local build sanity: `docker build --build-arg MITMPROXY_VERSION=12.2.3 -f images/mitmproxy/Dockerfile images/mitmproxy` succeeds; same with `MITMPROXY_VERSION=latest`. -- [ ] **Step 5**: Contract test green + full bats surface unaffected. -- [ ] **Step 6**: Commit: `security(images): pin mitmproxy base via images/mitmproxy.env; cron rebuilds latest only (#20)` - ---- - -### Task 3: rtk install-script pin - -**Files:** -- Modify: `cli/lib/rtk.bash` (`sct_rtk_docker_install_block`) -- Test: `cli/test/rtk/rtk.bats` (if it asserts the master URL — update; add assertion that the URL contains a 40-char SHA) - -**Steps:** - -- [ ] **Step 1**: Resolve the current rtk master commit: `gh api repos/rtk-ai/rtk/commits/master --jq .sha`. Record it in the report. -- [ ] **Step 2**: Replace `raw.githubusercontent.com/rtk-ai/rtk/master/install.sh` with `.../rtk//install.sh`. Extend the comment: script pinned by SHA (freezes the fetched shell code); rtk's install.sh itself downloads a binary release, so the binary is NOT fully pinned until rtk publishes stable releases — revisit then. -- [ ] **Step 3**: `curl -fsSL | head -5` — sanity that the pinned raw URL serves the script. -- [ ] **Step 4**: `cd cli && ./run-tests.bash test/rtk/` green. -- [ ] **Step 5**: Commit: `security(rtk): pin install script to commit SHA (#20)` - ---- - -### Task 4: Docs — bump procedure + README touch-ups - -**Files:** -- Modify: `README.md` and/or `cli/README.md` - -**Steps:** - -- [ ] **Step 1**: `grep -n "mitmproxy:latest\|mitmproxy/mitmproxy" README.md cli/README.md` — update stale references to reflect pinning. -- [ ] **Step 2**: Add a short "Bumping the pinned mitmproxy version" subsection (likely cli/README.md near the pass-cli.env docs): edit `cli/lib/constants.bash` + `images/mitmproxy.env` (2 lines), the contract test enforces sync, master push publishes the new ghcr version tags, cron only refreshes `latest`. -- [ ] **Step 3**: Commit: `docs: document pinned mitmproxy version + bump procedure` - ---- - -### Task 5: Hands-on integration verification - -**Files:** none (evidence for PR body). - -- [ ] **Step 1**: Fresh project, provider none: generated `compose-proxy.yml` has `image: mitmproxy/mitmproxy:12.2.3`, no unresolved placeholder; `docker compose up -d --build` (pulls the pinned tag) → all healthy; `curl https://github.com` from agent → 200. -- [ ] **Step 2**: Fresh project, `--secret-provider 1password`: generated compose has `ghcr.io/virtuslab/sandcat-mitmproxy-op:12.2.3`. Rendering-only assertion (`docker compose config`) — the tag publishes to ghcr only after this lands on master; note that in the report. Do NOT `up` this variant. -- [ ] **Step 3**: Local image build both ways (pinned + latest build-arg) — already done in Task 2 Step 4; reference results. -- [ ] **Step 4**: Teardown; write `.superpowers/sdd/2026-08-17-pin-image-versions/task-5-report.md`. - -## Out of scope - -- Base images (devcontainers/base, debian:trixie-slim) — follow-up. -- Agent CLI + devbox installers — self-updating tools, low pin value. -- Renovate automation (Phase 2), digest pinning (Phase 3). diff --git a/docs/superpowers/specs/2026-08-17-pin-image-versions-design.md b/docs/superpowers/specs/2026-08-17-pin-image-versions-design.md deleted file mode 100644 index 73a232bb..00000000 --- a/docs/superpowers/specs/2026-08-17-pin-image-versions-design.md +++ /dev/null @@ -1,89 +0,0 @@ -# Pin image versions (issue #20, Phase 1) — Design - -## Goal - -Remove `latest`-tag supply-chain exposure from sandcat's security-critical image chain: the mitmproxy image (TLS interception + secret substitution) and the two derived secret-provider images (`sandcat-mitmproxy-op`, `sandcat-mitmproxy-pass`). Also freeze the rtk install script, currently fetched from a third-party repo's moving `master`. - -## Motivation - -`mitmproxy/mitmproxy:latest` sees all agent plaintext and holds real secret values. Today: - -- the generated `compose-proxy.yml` references `mitmproxy/mitmproxy:latest` — every `docker compose pull` silently swaps the security boundary for whatever upstream published; -- both `images/*/Dockerfile` build `FROM mitmproxy/mitmproxy:latest` and a weekly cron republishes `ghcr.io/virtuslab/sandcat-mitmproxy-{op,pass}:latest` — the tag users' projects reference is overwritten in place every Monday; -- rtk installs via `raw.githubusercontent.com/rtk-ai/rtk/master/install.sh` — any push to that repo's master executes as root in every sandcat image build. - -## Decisions (made with the maintainer) - -1. **Pin exact version** (`12.2.3` at time of writing), bumped manually; automation (Renovate) is Phase 2. -2. **Versioned tags are immutable; `latest` remains a tracking channel.** The weekly cron rebuilds and pushes **only `latest`** (built `FROM mitmproxy/mitmproxy:latest`). Versioned tags (`sandcat-mitmproxy-op:12.2.3`) are published only by a master push (a bump commit) and never overwritten by cron. -3. **Generated projects reference versioned tags** — both the public mitmproxy image (provider `none`) and the ghcr images (providers `1password`/`protonpass`). - -## Version source of truth — two files, one contract test - -The version is needed on two sides that don't share a runtime: - -- **CLI side** (ships in the installed sandcat tarball): `SCT_MITMPROXY_VERSION="12.2.3"` in `cli/lib/constants.bash`. Consumed by the template placeholder replacement and `apply_secret_provider`. -- **Build side** (CI workflows + Dockerfiles): `images/mitmproxy.env` with `MITMPROXY_VERSION=12.2.3`. Consumed by both image workflows as a `--build-arg`; both Dockerfiles take `ARG MITMPROXY_VERSION` (no default — same "no defaults on purpose" convention as the existing `pass-cli.env`). - -A **contract bats test** asserts the two values are equal, so a bump that touches only one side fails CI. Bump = edit 2 lines in 2 files. - -## Mechanics - -### CLI side - -- Template `cli/templates/devcontainer/sandcat/compose-proxy.yml`: `image: mitmproxy/mitmproxy:latest` → `image: mitmproxy/mitmproxy:__MITMPROXY_VERSION__`. -- `customize_agent_templates` (cli/lib/devcontainer.bash) already runs `apply_inline_placeholders` on compose-proxy.yml with `__MITM_HTTP2__` etc. — add `"__MITMPROXY_VERSION__" "$SCT_MITMPROXY_VERSION"` to that call. -- `apply_secret_provider` (cli/lib/composefile.bash): `ghcr.io/virtuslab/sandcat-mitmproxy-op:latest` → `...:$SCT_MITMPROXY_VERSION` (yq `env()` injection, matching the file's existing style); same for `-pass`. - -### Build side - -- `images/mitmproxy/Dockerfile` and `images/mitmproxy-pass/Dockerfile`: `ARG MITMPROXY_VERSION` + `FROM mitmproxy/mitmproxy:${MITMPROXY_VERSION}`. -- Both workflows (`build-mitmproxy-image.yml`, `build-mitmproxy-pass-image.yml`): - - **push to master** (bump commit; `paths` trigger already covers `images/**`): read `images/mitmproxy.env`, pass as build-arg, tag with the version + sha. Do NOT move `latest`. - - **schedule (weekly cron)**: pass `MITMPROXY_VERSION=latest` as build-arg, tag ONLY `latest`. - - **pull_request**: build with the pinned version, no push (keep current PR behavior). -- Workflows also gain `paths: images/mitmproxy.env` in triggers so the bump commit rebuilds both images even when only the env file changes. - -### rtk - -`cli/lib/rtk.bash` install block: pin the install script to a specific commit SHA (`raw.githubusercontent.com/rtk-ai/rtk//install.sh`). Honest limitation, documented in the comment: rtk publishes only `dev-*` pre-releases today, and its install.sh fetches a binary release internally — the SHA pin freezes the *script* (closing the arbitrary-shell-injection-via-master vector) but not necessarily the *binary*. Full binary pinning is deferred until rtk ships stable releases. - -## Effective flow after the change - -``` -Bump commit (2 lines: constants.bash + images/mitmproxy.env) - │ push to master, paths trigger - ▼ -CI builds FROM mitmproxy:12.3.0 → publishes sandcat-mitmproxy-op:12.3.0 (+sha) - │ - ▼ -Same commit's CLI generates projects referencing: - - mitmproxy/mitmproxy:12.3.0 (provider none) - - ghcr.io/.../sandcat-mitmproxy-op:12.3.0 (provider 1password) - -Weekly cron: builds FROM mitmproxy:latest → pushes ONLY ghcr :latest (tracking channel, untouched by projects) -``` - -## Out of scope (deferred, with rationale) - -- **Base images** (`mcr.microsoft.com/devcontainers/base:debian`, `debian:trixie-slim`) — agent-side, inside the sandbox boundary; lower criticality. Follow-up. -- **Agent CLI installers** (claude.ai, chatgpt.com, cursor.com) — vendor-official scripts for tools that self-update at runtime; a pin buys little. -- **devbox installer** — same category. -- **Phase 2**: Renovate automation (regex managers for the bash-heredoc Dockerfile fragments). **Phase 3**: digest pinning. - -## Testing - -- Contract bats test: `SCT_MITMPROXY_VERSION` == `images/mitmproxy.env` value; template placeholder resolves (no `__MITMPROXY_VERSION__` left in generated compose-proxy.yml). -- Existing `regression.bats` `assert_proxy_service` asserts `image == "mitmproxy/mitmproxy:latest"` — update to the pinned reference (this is a deliberate assertion change mandated by the feature, not a weakening). -- Integration: generated project (provider none) pulls and runs the pinned public image end-to-end; provider 1password renders the versioned ghcr tag (pull not asserted — the tag publishes only after this lands on master; noted in report). - -## Global Constraints - -- Pinned version at introduction: **12.2.3** (verified current `mitmproxy --version` of upstream latest). -- CLI constant: `SCT_MITMPROXY_VERSION` in `cli/lib/constants.bash`. -- Build env file: `images/mitmproxy.env`, key `MITMPROXY_VERSION`. -- Template placeholder literal: `__MITMPROXY_VERSION__`. -- Dockerfiles: `ARG MITMPROXY_VERSION` with **no default**. -- Cron semantics: schedule builds use `MITMPROXY_VERSION=latest` and push only the `latest` tag; version tags come only from master pushes. -- rtk: script pinned by commit SHA; comment documents the binary-pin limitation. -- No behavior change for generated projects beyond the image references. From ac905e20921c1c6c137ff33a388d496ace3c28ba Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Tue, 18 Aug 2026 12:45:59 +0200 Subject: [PATCH 11/13] docs: move stray Cursor note under sandcat init devcontainer The "Note: Cursor agent support..." paragraph sat between the pinning docs and the init-command reference since before this branch, but inserting the mitmproxy bump procedure directly above it made it read as part of the merge-day-race guidance. Move it into the `sandcat init devcontainer` section, where `--agent cursor` is documented and the note actually applies. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ --- cli/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/README.md b/cli/README.md index 297dd62f..1368d9a9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -113,11 +113,6 @@ Generated projects reference the pinned version from the CLI-side constant, so p **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. -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 devcontainer` Sets up a devcontainer configuration for an agent. Copies devcontainer template files and customizes the @@ -133,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. From aba74fea3a0273cb271244a0f51588e00889ecf0 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Tue, 18 Aug 2026 12:53:37 +0200 Subject: [PATCH 12/13] docs(images): explain why mitmproxy.env lives at the images/ level pass-cli.env sits next to its single consumer's Dockerfile; mitmproxy.env is consumed by BOTH image builds, so it lives in their common parent. Spell out the placement rule so the asymmetry doesn't read as an accident. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DFCiFbv1Cpr7yzCU8ftvzZ --- images/mitmproxy.env | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/images/mitmproxy.env b/images/mitmproxy.env index ecfa2e20..70c275c4 100644 --- a/images/mitmproxy.env +++ b/images/mitmproxy.env @@ -1,5 +1,11 @@ # Single source of truth for the pinned mitmproxy base image version. # +# Placement note: this file lives at the images/ level (not inside a single +# image directory like pass-cli.env) because BOTH image builds consume it — +# images/mitmproxy/ and images/mitmproxy-pass/ each build FROM the mitmproxy +# base pinned here. Per-image config stays next to its Dockerfile; config +# shared across images lives in their common parent. +# # Consumed by: # - .github/workflows/build-mitmproxy-image.yml (loaded, then passed # in as --build-arg for non-scheduled builds) From 762ee63c283ac47aca8984aa95198afdc451de6e Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Wed, 2 Sep 2026 10:45:08 +0200 Subject: [PATCH 13/13] =?UTF-8?q?ci:=20simplify=20tag=20policy=20=E2=80=94?= =?UTF-8?q?=20latest=20is=20the=20newest=20pinned=20master=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: drop the dual-channel design where ghcr `latest` tracked upstream mitmproxy:latest via the weekly cron. That kept the exact supply-chain exposure this PR closes alive for already-generated projects, which reference ghcr `latest`. Now every published image contains the pinned mitmproxy from images/mitmproxy.env, and `latest` simply means the newest master build — so pre-pinning projects get the pinned base for free, and a Dockerfile hotfix reaches them on the next master push instead of the next cron. The weekly cron remains as a build-only canary against upstream mitmproxy:latest: a red run flags a breaking upstream release, and nothing is ever pushed from it. Docs updated to match (cli/README bump procedure, stale `:latest` mention in the upstream-CA section). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build-mitmproxy-image.yml | 33 ++++++++++--------- .../workflows/build-mitmproxy-pass-image.yml | 33 ++++++++++--------- README.md | 2 +- cli/README.md | 4 +-- 4 files changed, 37 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build-mitmproxy-image.yml b/.github/workflows/build-mitmproxy-image.yml index 4c9de9b8..a4885455 100644 --- a/.github/workflows/build-mitmproxy-image.yml +++ b/.github/workflows/build-mitmproxy-image.yml @@ -13,7 +13,7 @@ on: - '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: @@ -43,10 +43,11 @@ jobs: if: github.event_name != 'schedule' run: grep -m1 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV" - - name: Use latest mitmproxy for the scheduled tracking build - # Scheduled builds intentionally bypass the pin to track upstream - # mitmproxy:latest; they publish only the `latest` tag, never a - # version tag, so this can't be mistaken for the pinned image. + - 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" @@ -55,16 +56,16 @@ jobs: uses: docker/metadata-action@v5 with: images: ghcr.io/virtuslab/sandcat-mitmproxy-op - # Tag policy: schedule builds publish only `latest` (tracking - # channel). The pinned tag is immutable and can only be - # published from master — a push (hard-restricted to master by the - # trigger above) or a workflow_dispatch run explicitly on master — - # so a manual dispatch from a feature branch can never overwrite - # it. Off-master dispatch still gets a sha + branch-name tag (never - # tagless, never colliding with the canonical tag). PRs keep their - # existing ref tag and are never pushed (see `push:` below). + # 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 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=raw,value=latest,enable=${{ github.event_name == 'schedule' }} + 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' }} @@ -76,11 +77,11 @@ jobs: 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 }} build-args: | MITMPROXY_VERSION=${{ env.MITMPROXY_VERSION }} - # 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 diff --git a/.github/workflows/build-mitmproxy-pass-image.yml b/.github/workflows/build-mitmproxy-pass-image.yml index a6537cfa..82a896d2 100644 --- a/.github/workflows/build-mitmproxy-pass-image.yml +++ b/.github/workflows/build-mitmproxy-pass-image.yml @@ -13,7 +13,7 @@ on: - '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: @@ -48,10 +48,11 @@ jobs: if: github.event_name != 'schedule' run: grep -m1 '^MITMPROXY_VERSION=' images/mitmproxy.env >> "$GITHUB_ENV" - - name: Use latest mitmproxy for the scheduled tracking build - # Scheduled builds intentionally bypass the pin to track upstream - # mitmproxy:latest; they publish only the `latest` tag, never a - # version tag, so this can't be mistaken for the pinned image. + - 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" @@ -60,16 +61,16 @@ jobs: uses: docker/metadata-action@v5 with: images: ghcr.io/virtuslab/sandcat-mitmproxy-pass - # Tag policy: schedule builds publish only `latest` (tracking - # channel). The pinned tag is immutable and can only be - # published from master — a push (hard-restricted to master by the - # trigger above) or a workflow_dispatch run explicitly on master — - # so a manual dispatch from a feature branch can never overwrite - # it. Off-master dispatch still gets a sha + branch-name tag (never - # tagless, never colliding with the canonical tag). PRs keep their - # existing ref tag and are never pushed (see `push:` below). + # 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 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=raw,value=latest,enable=${{ github.event_name == 'schedule' }} + 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' }} @@ -81,7 +82,7 @@ jobs: 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: | @@ -89,6 +90,6 @@ jobs: 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 diff --git a/README.md b/README.md index 557f142e..6b8d4109 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/README.md b/cli/README.md index 1368d9a9..398671f8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -106,8 +106,8 @@ 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 new versioned `ghcr.io/virtuslab/sandcat-mitmproxy-op` and `ghcr.io/virtuslab/sandcat-mitmproxy-pass` tags -5. The weekly cron job rebuilds only the `latest` tag from upstream mitmproxy and never touches versioned tags +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.