From 6233d91d7a80705bb066882da6df013eee0e1d51 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Fri, 18 Sep 2026 18:40:39 +0200 Subject: [PATCH 1/8] feat(sandbox-kit): version and publish the Docker Sandboxes kit Give the chainloop-trace-claude kit the same release lifecycle the Helm chart already has: it declares a `version:` that tracks the Chainloop release whose CLI it ships, the release bump script rewrites it alongside the chart's appVersion, and merging that bump PR publishes the kit to docker.io/chainloop/sbx-kit-claude: with a signature and SLSA provenance attached. Docker Hub rather than ghcr because sbx's default kit.allowedSources is already ["docker.io/"], so consumers need no host settings change to pull the kit. The spec moves under devel/sandbox-kit/claude/ to make room for further kits; the packaging workflow and the bump script both glob the directory, so additional kits are picked up without further edits. sbxenv.yaml keeps pointing at the in-repo copy so the local environment always runs the kit as it exists on the current branch. Also corrects the README's sbx run invocations: the kit is `kind: sandbox`, so it is the agent and belongs in the positional slot, not behind --kit, which takes mixins only. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: 7da50f6b-428c-4749-82b5-562ae1ba890e --- .github/workflows/package_sandbox_kit.yaml | 100 ++++++++++++++++++ .../utils/bump-chart-and-dagger-version.sh | 11 +- devel/sandbox-kit/README.md | 19 ++-- devel/sandbox-kit/{ => claude}/spec.yaml | 4 + sbxenv.yaml | 16 ++- 5 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/package_sandbox_kit.yaml rename devel/sandbox-kit/{ => claude}/spec.yaml (99%) diff --git a/.github/workflows/package_sandbox_kit.yaml b/.github/workflows/package_sandbox_kit.yaml new file mode 100644 index 000000000..c84fb5705 --- /dev/null +++ b/.github/workflows/package_sandbox_kit.yaml @@ -0,0 +1,100 @@ +name: Package Sandbox Kit + +on: + # Only push the kit if the kit sources have changed. The release bump PR + # rewrites each spec.yaml `version:`, so merging it is what triggers a publish + # — the same shape as package_chart.yaml reacting to deployment/chainloop/** + push: + branches: + - main + paths: + - devel/sandbox-kit/** + +permissions: read-all + +jobs: + package: + name: Package and push Sandbox Kit + runs-on: ubuntu-latest + permissions: + id-token: write # required for SLSA provenance and keyless kit signing + env: + CHAINLOOP_WORKFLOW_NAME: "sandbox-kit-package" + CHAINLOOP_PROJECT: "chainloop" + # Docker Sandboxes ships Linux packages only on tagged releases, not on + # nightly, so this is pinned to a stable tag and bumped by hand. + SBX_VERSION: "v0.43.0" + KIT_DIR: "devel/sandbox-kit/claude" + KIT_REPO: "docker.io/chainloop/sbx-kit-claude" + steps: + - name: Install Chainloop + run: | + curl -sfL https://dl.chainloop.dev/cli/install.sh | bash -s + + - name: Install Docker Sandboxes CLI + run: | + curl -sfL -o /tmp/sbx.deb \ + "https://github.com/docker/sbx-releases/releases/download/${SBX_VERSION}/DockerSandboxes-linux-amd64-ubuntu2404.deb" + sudo apt-get install -y /tmp/sbx.deb + sbx version + + - name: Docker login to Docker Hub + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false + + - name: Read kit version + id: kit_version + run: | + # The kit's own spec.yaml is the source of truth; bump-chart-and-dagger-version.sh + # keeps it in step with the chart's appVersion on every release. + kit_version=$(yq -r '.version' "${KIT_DIR}/spec.yaml") + if [[ -z "${kit_version}" || "${kit_version}" == "null" ]]; then + echo "::error::${KIT_DIR}/spec.yaml declares no version:" + exit 1 + fi + echo "kit_version=${kit_version}" >> $GITHUB_OUTPUT + + - name: Validate kit + # Fails loudly here rather than halfway through a push if the pinned sbx + # release does not understand something the spec declares. + run: sbx kit validate "./${KIT_DIR}" + + - name: Add Attestation (Sandbox Kit) and Push Kit + run: | + kit_version="${{ steps.kit_version.outputs.kit_version }}" + + # Force the version declared in the kit spec and make sure it exists in + # the project by passing --existing-version; if it does not exist the + # attestation fails, and the version needs creating before a re-run. + chainloop attestation init --org chainloop --workflow ${CHAINLOOP_WORKFLOW_NAME} --project ${CHAINLOOP_PROJECT} --version ${kit_version} --existing-version + + # Push the kit. --sign is keyless (Fulcio + Rekor) off the ambient + # GitHub OIDC token, and every push also attaches SLSA provenance. + sbx kit push "./${KIT_DIR}" "${KIT_REPO}:${kit_version}" --sign + + # Attest the published kit + chainloop attestation add --name sandbox-kit --value "${KIT_REPO}:${kit_version}" + env: + # Needed for commit signature verification: https://docs.chainloop.dev/concepts/attestations#commit-verification + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Finish and Record Attestation + if: ${{ success() }} + run: | + chainloop attestation push + + - name: Mark attestation as failed + if: ${{ failure() }} + run: | + chainloop attestation reset + + - name: Mark attestation as cancelled + if: ${{ cancelled() }} + run: | + chainloop attestation reset --trigger cancellation diff --git a/.github/workflows/utils/bump-chart-and-dagger-version.sh b/.github/workflows/utils/bump-chart-and-dagger-version.sh index a9c40547a..1501c6f02 100755 --- a/.github/workflows/utils/bump-chart-and-dagger-version.sh +++ b/.github/workflows/utils/bump-chart-and-dagger-version.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Bump Helm Chart version, appVersion to a given version number +# Bump Helm Chart version, appVersion, Dagger and sandbox kit versions to a given version number set -e @@ -59,3 +59,12 @@ if [[ -n "${platform_version}" && "${platform_version}" != "null" ]]; then sed -i "s/platformVersion = \"v.*\"/platformVersion = \"${platform_version}\"/" "${dagger_main}" fi +## Update the Docker Sandboxes kit versions +# Each kit declares the Chainloop release whose CLI it ships, so they track semVer +# like appVersion does. The path is fixed rather than an argument because this is a +# directory of kits that grows, and `schemaVersion:` is left alone by the ^version anchor. +for kit_spec in devel/sandbox-kit/*/spec.yaml; do + [ -e "${kit_spec}" ] || continue + sed -i "s#^version:.*#version: ${semVer}#g" "${kit_spec}" +done + diff --git a/devel/sandbox-kit/README.md b/devel/sandbox-kit/README.md index 13ab2dd62..f8823d7e3 100644 --- a/devel/sandbox-kit/README.md +++ b/devel/sandbox-kit/README.md @@ -6,7 +6,7 @@ already wired in, so a session working on this repo is recorded as an tools and MCP servers called, AI-vs-human line attribution — and attested to Chainloop without the developer setting anything up. -`spec.yaml` here is the kit — self-contained, no secrets, and heavily commented; read it for the design +`claude/spec.yaml` is the kit — self-contained, no secrets, and heavily commented; read it for the design rationale, the `extends: claude` inheritance notes, and the gRPC-vs-egress-proxy analysis. It started life in the `chainloop-trace-docker-sandbox` PoC repo, which additionally carries the long-form write-up and the running list of upstream Docker bugs. @@ -51,7 +51,7 @@ value from your shell, and it overrides the kit's own default: ```bash export CHAINLOOP_TOKEN=cl_... -sbx run --clone --kit ./devel/sandbox-kit -e CHAINLOOP_TOKEN chainloop-trace-claude +sbx run --clone -e CHAINLOOP_TOKEN ./devel/sandbox-kit/claude ``` …or skip the token entirely and **authenticate from your existing `chainloop auth login` session**, by @@ -61,9 +61,9 @@ mounting the config the CLI already wrote on your machine: CFG="$HOME/Library/Application Support/chainloop" # macOS # CFG="$HOME/.config/chainloop" # Linux -sbx run --clone --kit ./devel/sandbox-kit \ +sbx run --clone \ --kit-arg chainloopConfig="$CFG/config.toml" \ - chainloop-trace-claude \ + ./devel/sandbox-kit/claude \ . "${CFG}:ro" ``` @@ -82,7 +82,7 @@ attach: | | exported `$CHAINLOOP_TOKEN` | API token, explicit | `config.toml` | | --- | --- | --- | --- | | `sbx env run` | not supported — no `-e` flag, and nothing interpolates in the file | `--env-arg chainloopToken=…` | needs an overlay file (below) | -| `sbx run --kit` | `-e CHAINLOOP_TOKEN` | `--kit-arg chainloopToken=…` | `--kit-arg chainloopConfig=…` + a `:ro` mount | +| `sbx run ./devel/sandbox-kit/claude` | `-e CHAINLOOP_TOKEN` | `--kit-arg chainloopToken=…` | `--kit-arg chainloopConfig=…` + a `:ro` mount | Supply one of them. With no token **and** no config the sandbox refuses to start rather than run an untraced session — a session that records nothing is worse than one that never began, because you only find out when @@ -111,9 +111,9 @@ same injection path, so it would flip the Chainloop hosts to the intercepted pat printf 'chainloopToken=%s\n' "$CHAINLOOP_TOKEN" > ~/.config/chainloop/kit-args chmod 600 ~/.config/chainloop/kit-args -sbx run --clone --kit ./devel/sandbox-kit \ +sbx run --clone \ --kit-args-file ~/.config/chainloop/kit-args \ - chainloop-trace-claude + ./devel/sandbox-kit/claude ``` `sbx env run` has the equivalent `--env-args-file`. @@ -167,6 +167,11 @@ in the entrypoint wrapper, so attach once first. - `chainloopConfig` does not appear in `sbx env plan` — the plan only renders args pinned in a file. The value still reaches the sandbox; its absence is not a failure. - Mounts are fixed at creation. `sbx env run` on an existing sandbox re-attaches without re-provisioning. +- **This kit is `kind: sandbox`, so it *is* the agent** — it goes in `sbx run`'s positional slot, not behind +`--kit`, which takes mixins only. `sbx run --clone --kit ./devel/sandbox-kit/claude` fails with the unhelpful +`'sbx run' requires at least 1 argument`, because the flag swallowed the reference and left no agent to run. +`sbxenv.yaml` splits the same thing across two keys — `kits:` loads the artifact, `agent:` names what to run +from it — which is why the kit's own name appears there and nowhere on an `sbx run` line. ## Why nightly diff --git a/devel/sandbox-kit/spec.yaml b/devel/sandbox-kit/claude/spec.yaml similarity index 99% rename from devel/sandbox-kit/spec.yaml rename to devel/sandbox-kit/claude/spec.yaml index 25ef8caf3..d2fd3bd6f 100644 --- a/devel/sandbox-kit/spec.yaml +++ b/devel/sandbox-kit/claude/spec.yaml @@ -18,6 +18,10 @@ schemaVersion: "2" kind: sandbox # REQUIRED: only kind:sandbox may set an entrypoint name: chainloop-trace-claude +# Tracks the Chainloop release this kit ships the CLI of. Bumped automatically on +# release by .github/workflows/utils/bump-chart-and-dagger-version.sh, alongside the +# chart's appVersion - do not edit by hand. +version: v1.109.0 displayName: Claude Code (Chainloop-traced) description: >- Claude Code traced with Chainloop. Repos already initialized for `chainloop trace` diff --git a/sbxenv.yaml b/sbxenv.yaml index e4954186f..f3bad9edd 100644 --- a/sbxenv.yaml +++ b/sbxenv.yaml @@ -52,12 +52,18 @@ args: Supply THIS or chainloopConfig — with neither, the kit's wrapper refuses to start rather than run an untraced session. kitPath: - default: ./devel/sandbox-kit + default: ./devel/sandbox-kit/claude description: >- - Path to the chainloop-trace-claude kit. Vendored into this repo so the - environment is self-contained; the upstream source is the PoC repo - (playground/chainloop-trace-docker-sandbox), and devel/sandbox-kit/README.md - says how to re-sync. Relative paths in kits: resolve against the INVOCATION + Path to the chainloop-trace-claude kit. Deliberately the IN-REPO copy and not + the published artifact, so this environment always runs the kit as it exists + on the current branch: no allowlist entry, no registry round trip, and a spec + edit takes effect without a release. Released copies go to + docker.io/chainloop/sbx-kit-claude: via + .github/workflows/package_sandbox_kit.yaml, tagged with the `version:` in the + kit's own spec.yaml — pass + --env-arg kitPath=docker.io/chainloop/sbx-kit-claude:vX.Y.Z to pin one. + devel/sandbox-kit/README.md says how to re-sync with the upstream PoC repo + (playground/chainloop-trace-docker-sandbox). Relative paths in kits: resolve against the INVOCATION CWD, not this file's directory (docker/sbx-releases#493, still open; reproduced on nightly rc1-441 — `workspace:` resolves against the file's dir, `kits:` does not), so run `sbx env run` FROM the repo root, or pass From 1fbd4688e820ef4da348b6c3e7d851deb56676b3 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Fri, 18 Sep 2026 18:59:25 +0200 Subject: [PATCH 2/8] feat(sandbox-kit): authenticate to Docker Hub with OIDC Replace the DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets with an OIDC connection, so publishing needs no long-lived Docker Hub credential in the repository. GitHub mints a short-lived identity token per run and Docker exchanges it for a registry token that expires with the job; access is governed by the connection's ruleset on the subject claim. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: 7da50f6b-428c-4749-82b5-562ae1ba890e --- .github/workflows/package_sandbox_kit.yaml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/package_sandbox_kit.yaml b/.github/workflows/package_sandbox_kit.yaml index c84fb5705..593d7b06b 100644 --- a/.github/workflows/package_sandbox_kit.yaml +++ b/.github/workflows/package_sandbox_kit.yaml @@ -17,7 +17,8 @@ jobs: name: Package and push Sandbox Kit runs-on: ubuntu-latest permissions: - id-token: write # required for SLSA provenance and keyless kit signing + contents: read + id-token: write # Docker Hub OIDC login, SLSA provenance and keyless kit signing env: CHAINLOOP_WORKFLOW_NAME: "sandbox-kit-package" CHAINLOOP_PROJECT: "chainloop" @@ -38,11 +39,18 @@ jobs: sudo apt-get install -y /tmp/sbx.deb sbx version + # OIDC rather than a stored token: GitHub mints a short-lived identity token + # per run and Docker exchanges it for a registry token that expires with the + # job, so there is no long-lived Docker Hub credential in this repo. Access is + # governed by the connection's ruleset, which matches the subject claim + # repo:chainloop-dev/chainloop:ref:refs/heads/main - adding an `environment:` + # to this job would change that claim and stop it matching. - name: Docker login to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + env: + DOCKERHUB_OIDC_CONNECTIONID: 361a9222-f648-4f62-baf4-5f500f7fbf54 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: chainloop - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: From 7ffa222abaac4bd1d34049b3bbe6f9ccc1c048b5 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Fri, 18 Sep 2026 19:04:39 +0200 Subject: [PATCH 3/8] feat(sandbox-kit): also publish the kit under the latest tag Each release moves docker.io/chainloop/sbx-kit-claude:latest to the version just published, so consumers can track the current kit without pinning. The attestation keeps naming the immutable tag. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: 7da50f6b-428c-4749-82b5-562ae1ba890e --- .github/workflows/package_sandbox_kit.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package_sandbox_kit.yaml b/.github/workflows/package_sandbox_kit.yaml index 593d7b06b..96afe0c4b 100644 --- a/.github/workflows/package_sandbox_kit.yaml +++ b/.github/workflows/package_sandbox_kit.yaml @@ -86,7 +86,14 @@ jobs: # GitHub OIDC token, and every push also attaches SLSA provenance. sbx kit push "./${KIT_DIR}" "${KIT_REPO}:${kit_version}" --sign - # Attest the published kit + # Move the floating tag to the same release. Note this is a second push + # rather than a retag, so :latest gets its own manifest digest even though + # the content is identical - compare the kit's `version:`, not the digest, + # to tell which release :latest currently points at. + sbx kit push "./${KIT_DIR}" "${KIT_REPO}:latest" --sign + + # Attest the published kit. The immutable tag, not :latest, since the + # attestation should keep naming this artifact after the tag moves on. chainloop attestation add --name sandbox-kit --value "${KIT_REPO}:${kit_version}" env: # Needed for commit signature verification: https://docs.chainloop.dev/concepts/attestations#commit-verification From 7c9bee12116f1fd665ea5f64c9bbba1908624c76 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Fri, 18 Sep 2026 19:10:01 +0200 Subject: [PATCH 4/8] fix(sandbox-kit): harden the packaging workflow (PFM-7373) Address the SAST and Kusari findings on the new workflow: - Replace the `curl | bash` installer with a fetch, sha256 digest check and then execute. The job holds an OIDC token that mints Docker Hub credentials and signs artifacts, so an unverified install script is a privilege escalation path. - Pass the kit version into the publish step through env rather than interpolating the step output into the shell source, removing the template injection surface. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: 7da50f6b-428c-4749-82b5-562ae1ba890e --- .github/workflows/package_sandbox_kit.yaml | 24 +++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/package_sandbox_kit.yaml b/.github/workflows/package_sandbox_kit.yaml index 96afe0c4b..267a6fd41 100644 --- a/.github/workflows/package_sandbox_kit.yaml +++ b/.github/workflows/package_sandbox_kit.yaml @@ -29,8 +29,20 @@ jobs: KIT_REPO: "docker.io/chainloop/sbx-kit-claude" steps: - name: Install Chainloop + # Deliberately NOT `curl ... | bash`: this job holds an OIDC token that can + # mint Docker Hub credentials and sign artifacts, so the installer is + # fetched, pinned by digest and only then executed. The installer itself + # verifies the CLI's checksums (and cosign-verifies their signature), so + # this closes the remaining gap, which is the script in transit. + # If dl.chainloop.dev publishes a new installer this step fails with a + # digest mismatch; re-pin with: + # curl -sfL https://dl.chainloop.dev/cli/install.sh | sha256sum + env: + INSTALLER_SHA256: 6ebcdb8edc6f22c92b6ac31a363f7d60da6e04c60c4fadda44169f18492ba46e run: | - curl -sfL https://dl.chainloop.dev/cli/install.sh | bash -s + curl -sfL https://dl.chainloop.dev/cli/install.sh -o /tmp/chainloop-install.sh + echo "${INSTALLER_SHA256} /tmp/chainloop-install.sh" | sha256sum -c - + bash /tmp/chainloop-install.sh - name: Install Docker Sandboxes CLI run: | @@ -75,16 +87,17 @@ jobs: - name: Add Attestation (Sandbox Kit) and Push Kit run: | - kit_version="${{ steps.kit_version.outputs.kit_version }}" + # KIT_VERSION arrives through env, not ${{ }} interpolation, so the value + # is never expanded into this script's source. # Force the version declared in the kit spec and make sure it exists in # the project by passing --existing-version; if it does not exist the # attestation fails, and the version needs creating before a re-run. - chainloop attestation init --org chainloop --workflow ${CHAINLOOP_WORKFLOW_NAME} --project ${CHAINLOOP_PROJECT} --version ${kit_version} --existing-version + chainloop attestation init --org chainloop --workflow ${CHAINLOOP_WORKFLOW_NAME} --project ${CHAINLOOP_PROJECT} --version ${KIT_VERSION} --existing-version # Push the kit. --sign is keyless (Fulcio + Rekor) off the ambient # GitHub OIDC token, and every push also attaches SLSA provenance. - sbx kit push "./${KIT_DIR}" "${KIT_REPO}:${kit_version}" --sign + sbx kit push "./${KIT_DIR}" "${KIT_REPO}:${KIT_VERSION}" --sign # Move the floating tag to the same release. Note this is a second push # rather than a retag, so :latest gets its own manifest digest even though @@ -94,8 +107,9 @@ jobs: # Attest the published kit. The immutable tag, not :latest, since the # attestation should keep naming this artifact after the tag moves on. - chainloop attestation add --name sandbox-kit --value "${KIT_REPO}:${kit_version}" + chainloop attestation add --name sandbox-kit --value "${KIT_REPO}:${KIT_VERSION}" env: + KIT_VERSION: ${{ steps.kit_version.outputs.kit_version }} # Needed for commit signature verification: https://docs.chainloop.dev/concepts/attestations#commit-verification GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From f33a2d3eb514205a8289a8f4a68acd05cb75c990 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Sun, 20 Sep 2026 10:13:25 +0200 Subject: [PATCH 5/8] feat(sandbox-kit): support persistent tracing only (PFM-7373) The kit now requires a repository that has already been initialized with `chainloop trace init` and refuses to start otherwise, telling the user to run it in the repository first. Identity always comes from the committed .chainloop.yml, so the traceMode, chainloopOrg, chainloopProject and chainloopWorkflow arguments are gone. `chainloop trace run` is dropped deliberately: it ignores .chainloop.yml by design, and its teardown wipes .git/chainloop-trace/ and strips the committed hooks, which is destructive on exactly the repositories this kit accepts. The trade-off is that a session whose work is never pushed attests nothing. Also adds a usage header pointing at the guide, drops the edition wording from the CLI install and egress comments, and condenses the commentary. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: 7da50f6b-428c-4749-82b5-562ae1ba890e --- devel/sandbox-kit/README.md | 15 +- devel/sandbox-kit/claude/spec.yaml | 246 +++++++++++------------------ sbxenv.yaml | 14 +- 3 files changed, 110 insertions(+), 165 deletions(-) diff --git a/devel/sandbox-kit/README.md b/devel/sandbox-kit/README.md index f8823d7e3..f97573561 100644 --- a/devel/sandbox-kit/README.md +++ b/devel/sandbox-kit/README.md @@ -6,6 +6,8 @@ already wired in, so a session working on this repo is recorded as an tools and MCP servers called, AI-vs-human line attribution — and attested to Chainloop without the developer setting anything up. +Full guide: **https://docs.chainloop.dev/guides/docker-sandboxes** _(publishing shortly — until it lands, this file is the reference)_ + `claude/spec.yaml` is the kit — self-contained, no secrets, and heavily commented; read it for the design rationale, the `extends: claude` inheritance notes, and the gRPC-vs-egress-proxy analysis. It started life in the `chainloop-trace-docker-sandbox` PoC repo, which additionally carries the long-form write-up and the @@ -31,8 +33,15 @@ interactive login inside the sandbox. Independent of everything Chainloop. Every command here runs **from the repository root**, and both ways end up in the same place: this repo is already initialized for `chainloop trace` (`.chainloop.yml` + the hooks in `.claude/settings.json`), so the -kit runs in **persistent** mode, takes its identity — org, project, workflow — from `.chainloop.yml`, and -pushes the attestation on `git push`. +kit takes its identity — org, project, workflow — from `.chainloop.yml` and pushes the attestation on +`git push`. + +**The kit supports persistent tracing only, and refuses to start without it.** Point it at a repository that +has not been initialized and it exits with instructions to run `chainloop trace init` there first. The CLI's +other mode, `chainloop trace run`, is deliberately not offered: it ignores `.chainloop.yml` by design and its +teardown wipes `.git/chainloop-trace/` and strips the committed hooks — destructive on exactly the repos this +kit accepts. The trade-off is that **a session whose work is never pushed attests nothing**, so push from +inside the sandbox before it is reclaimed. ### 1. Through the environment file @@ -74,7 +83,7 @@ attach: ``` [chainloop-trace] Adopted chainloop config from /Users/…/chainloop/config.toml -[chainloop-trace] Repo already initialized for chainloop trace - persistent mode +[chainloop-trace] Repo initialized for chainloop trace - persistent mode ``` ### Which to use diff --git a/devel/sandbox-kit/claude/spec.yaml b/devel/sandbox-kit/claude/spec.yaml index d2fd3bd6f..5b99216e5 100644 --- a/devel/sandbox-kit/claude/spec.yaml +++ b/devel/sandbox-kit/claude/spec.yaml @@ -1,20 +1,31 @@ -# !!! REQUIRES an sbx@nightly build - NOT the v0.39.0 stable release, and not -# !!! only for the `args:` block below (nightly-only) but because the kit is -# !!! broken on stable. This kit inherits the built-in `claude` agent with -# !!! `extends:`, which relies on ADDITIVE merge: the parent's setup commands, -# !!! environment variables and network rules must survive alongside the ones -# !!! declared here. A child `setup:` block silently drops the parent's -# !!! (docker/sbx-releases#415), taking Claude's trust-flag seeding, -# !!! ~/.claude/settings.json and the `claude mcp add mcp-gateway` registration -# !!! with it, and leaving the persistent ~/.claude/* volumes root:root so the -# !!! agent cannot write the transcript that `chainloop trace` reads. -# !!! <=v0.38.1 BROKEN -# !!! nightly-202608180320 (rc1-245) ok -# !!! v0.39.0 stable (def8cb0) BROKEN AGAIN - #415 regressed in the release -# !!! nightly-202608240324 (rc1-441) ok <- verified -# !!! On a broken build this kit produces a sandbox that starts but has no MCP -# !!! gateway, stops at the trust dialog, and attests nothing. Check with: -# !!! sbx exec -- stat -c '%U:%G' /home/agent/.claude/projects +# Claude Code in a Docker Sandbox, traced with Chainloop. +# +# GUIDE: https://docs.chainloop.dev/guides/docker-sandboxes (publishing shortly) +# +# Quick start - the repository must already be initialized for tracing, and the +# kit refuses to start if it is not: +# +# chainloop trace init # once per repo, commit the result +# export CHAINLOOP_TOKEN= +# sbx run --clone -e CHAINLOOP_TOKEN chainloop/sbx-kit-claude:latest +# +# Work as usual inside the sandbox; the session is attested when you `git push` +# from it. No push, no attestation - push before the sandbox is reclaimed. +# +# Published on every release to docker.io/chainloop/sbx-kit-claude by +# .github/workflows/package_sandbox_kit.yaml. To run the in-repo copy instead: +# sbx run --clone -e CHAINLOOP_TOKEN ./devel/sandbox-kit/claude +# See devel/sandbox-kit/README.md for the sharp edges. +# +# !!! BUILD REQUIREMENT - this kit needs an sbx build where `extends:` merges +# !!! ADDITIVELY. A child `setup:` block silently dropping the parent's +# !!! (docker/sbx-releases#415) takes Claude's trust-flag seeding, MCP gateway +# !!! registration and the ~/.claude/* volume ownership with it, so the sandbox +# !!! starts but cannot write the transcript `chainloop trace` reads: it attests +# !!! NOTHING, with no error. Known: <=v0.38.1 broken, nightly rc1-245 ok, +# !!! v0.39.0 stable broken (#415 regressed in the release), rc1-441 ok. +# !!! Check a build with: +# !!! sbx exec -- stat -c '%U:%G' /home/agent/.claude/projects # want agent:agent schemaVersion: "2" kind: sandbox # REQUIRED: only kind:sandbox may set an entrypoint name: chainloop-trace-claude @@ -24,35 +35,39 @@ name: chainloop-trace-claude version: v1.109.0 displayName: Claude Code (Chainloop-traced) description: >- - Claude Code traced with Chainloop. Repos already initialized for `chainloop trace` - use their committed config (attested on `git push`); all other repos are - wrapped in `chainloop trace run` (attested on clean agent exit). Fork of the - built-in `claude` agent. + Claude Code traced with Chainloop. Requires a repository already initialized + for `chainloop trace`: identity comes from its committed .chainloop.yml and the + attestation is pushed on `git push`. Fork of the built-in `claude` agent. # Everything the caller has to supply arrives as a kit ARGUMENT, so this file # holds no secrets and is committed as-is. `sbx kit validate ./kit` names any -# argument it still needs, and traceMode's enum is enforced before the sandbox is -# built rather than by the wrapper at runtime. +# argument it still needs. +# +# ONE MODE ON PURPOSE - persistent, i.e. `chainloop trace init` has already +# written organization/projectName (and optionally the workflow, else +# "ai-coding-session") into the repo's .chainloop.yml, and the managed pre-push +# hook reads them from there. The kit contributes NOTHING but the token, which is +# why there are no org/project/workflow arguments. # -# WHY org/project/workflow are arguments rather than values baked in here: the -# two tracing modes take their attestation identity from opposite places. -# persistent - `chainloop trace init` wrote organization/projectName (and -# optionally the workflow, else "ai-coding-session") into the -# repo's .chainloop.yml, and the push-time hook reads them from -# there. The kit contributes NOTHING but the token. -# trace run - deliberately isolated: "it ignores .chainloop.yml entirely. The -# attestation identity must come from the --org, --project, -# --workflow flags (mandatory)" (`chainloop trace run --help`). -# So the three values are dead weight in the mode this kit uses most, and -# mandatory in the other. Asking for them per-invocation is the only shape that -# is honest about that. +# The other mode the CLI offers, `chainloop trace run`, is deliberately NOT +# supported here: +# - it "ignores .chainloop.yml entirely. The attestation identity must come +# from the --org, --project, --workflow flags (mandatory)" +# (`chainloop trace run --help`), so it needs three arguments that are dead +# weight for every repo this kit is actually pointed at; +# - its teardown wipes .git/chainloop-trace/ and strips the committed agent +# hooks from .claude/settings.json - destructive on a trace-initialized repo, +# which is the only kind this kit accepts. +# The trade-off that buys: a session whose work is never pushed attests nothing. +# That is intended - unpushed work is discarded work, and the sandbox is +# disposable. Push from inside the sandbox before it is reclaimed. args: chainloopToken: default: "" description: >- Chainloop org-scoped API token (chainloop organization api-token create). - Authenticates the attestation push in BOTH modes; in persistent mode it - must have access to the org pinned in the repo's .chainloop.yml. + Authenticates the attestation push, so it must have access to the org + pinned in the repo's .chainloop.yml. OPTIONAL only because chainloopConfig is the other way to authenticate - supply one or the other, or the wrapper refuses to start. When both are present this one WINS: the CLI prefers an exported CHAINLOOP_TOKEN over a @@ -65,36 +80,12 @@ args: i.e. where you mounted the host's, which sbx mounts at the SAME absolute path it has on the host. The wrapper copies it to ~/.config/chainloop/config.toml so every later `chainloop` invocation - finds it, including the git hooks that persistent mode runs as their own + finds it, including the managed git hooks, which run as their own processes (a --config flag would not reach those). Brings the org and the control-plane/CAS/platform endpoints with it, which is what makes this worthwhile for self-hosted. NOTE its [auth] token is your personal login session: short-lived (~days) and your full identity. Prefer chainloopToken for anything unattended. - traceMode: - default: auto - enum: [auto, run, persistent] - description: >- - auto = persistent when the repo is already trace-initialized, else trace - run. run = always single-shot (DESTRUCTIVE on a trace-initialized repo: - teardown wipes .git/chainloop-trace/ and strips the committed agent - hooks). persistent = force persistent. - chainloopOrg: - default: "" - description: >- - Chainloop organization. `trace run` mode ONLY - ignored in persistent - mode, where the org comes from the repo's .chainloop.yml. - chainloopProject: - default: "" - description: >- - Chainloop project. `trace run` mode ONLY - ignored in persistent mode. - chainloopWorkflow: - default: ai-coding-session - description: >- - Chainloop workflow name. `trace run` mode ONLY. Matches the default - `chainloop trace init` writes, so both modes land on the same workflow - unless you say otherwise. - # Inherit the built-in `claude` agent instead of restating it. This kit then # declares ONLY the Chainloop delta; everything below comes for free and stays in # step with sbx upgrades instead of drifting in a hand-copied spec: @@ -112,23 +103,21 @@ extends: claude sandbox: # REPLACES the parent's [claude, --dangerously-skip-permissions] -> the wrap - # point. The wrapper re-adds that flag when it execs claude. (v1 spelled this - # `entrypoint.run: [...]`; v2 takes the argv array directly.) + # point. The wrapper re-adds that flag when it execs claude. entrypoint: ["/home/agent/.local/bin/cl-trace-wrap.sh"] -setup: # v1: `commands:` +setup: install: - # Install the EE chainloop CLI (the public installer defaults to EE, which - # is the edition that ships `trace`). The installer requires the target - # dir to exist, so create it first. Run as root so we can write to - # /usr/local/bin (hooks invoke `chainloop` by bare name -> must be on PATH). + # Install the chainloop CLI. The installer requires the target dir to exist, + # so create it first. Run as root so we can write to /usr/local/bin (hooks + # invoke `chainloop` by bare name -> must be on PATH). - command: >- mkdir -p /usr/local/bin && curl -sfL https://dl.chainloop.dev/cli/install.sh | bash -s -- --path /usr/local/bin && chainloop version user: "0" - description: Install the EE chainloop CLI on PATH - files: # v1: `commands.initFiles:` + description: Install the chainloop CLI on PATH + files: - path: /home/agent/.local/bin/cl-trace-wrap.sh mode: "0755" content: | @@ -186,89 +175,46 @@ setup: # v1: `commands:` --kit-arg chainloopConfig=" fi - # NB: shell default-expansion (dollar-brace VAR colon-dash default) is - # rejected by the kit validator inside setup.files content - WORKDIR is - # the only supported placeholder - so the default is spelled out long. - # Every arg has a default, so the kit always defines these and bare - # $VAR is safe under `set -u`. - mode="$CHAINLOOP_TRACE_MODE" - if [ -z "$mode" ]; then - mode=auto - fi - # traceMode's enum is enforced by sbx before the sandbox is built, so a - # bad value here can only arrive via a runtime `-e` override. - case "$mode" in - auto|run|persistent) ;; - *) die "invalid CHAINLOOP_TRACE_MODE='$mode' (want auto|run|persistent)" ;; - esac - + # Persistent tracing is the only mode: the repo must already be + # initialized, and the identity comes from its committed .chainloop.yml. + # (Every kit arg has a default, so the vars above are always defined and + # bare $VAR is safe under `set -u`; the validator rejects shell + # default-expansion inside setup.files content.) + repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") detected=0 - if [ "$mode" != run ]; then - repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") - for f in "$repo_root/.chainloop.yml" "$repo_root/.chainloop.yaml"; do - if [ -f "$f" ] && grep -qE '^[[:space:]]*projectName:[[:space:]]*["'"'"']?[A-Za-z0-9]' "$f" \ - && grep -q "chainloop trace hook" "$repo_root/.claude/settings.json" 2>/dev/null; then - detected=1 - break - fi - done - fi + for f in "$repo_root/.chainloop.yml" "$repo_root/.chainloop.yaml"; do + if [ -f "$f" ] && grep -qE '^[[:space:]]*projectName:[[:space:]]*["'"'"']?[A-Za-z0-9]' "$f" \ + && grep -q "chainloop trace hook" "$repo_root/.claude/settings.json" 2>/dev/null; then + detected=1 + break + fi + done - if [ "$mode" = auto ]; then - if [ "$detected" = 1 ]; then mode=persistent; else mode=run; fi - fi + if [ "$detected" != 1 ]; then + die "this repository is not initialized for chainloop trace. - if [ "$mode" = persistent ]; then - if [ "$detected" != 1 ]; then - die "traceMode=persistent, but this repo carries no committed trace config - (needs .chainloop.yml with projectName AND the 'chainloop trace hook' - entries in .claude/settings.json). Run 'chainloop trace init' in the - repo, or use traceMode=run with chainloopOrg/chainloopProject." - fi - echo "[chainloop-trace] Repo already initialized for chainloop trace - persistent mode" >&2 - echo "[chainloop-trace] identity from .chainloop.yml; attestation is pushed on 'git push'" >&2 - run_plain "$@" - fi + Run this in the repository first, then start the sandbox again: - # trace run mode: .chainloop.yml is ignored by design, so the identity - # has to come from the kit args. - missing="" - if [ -z "$CHAINLOOP_ORG" ]; then missing="$missing chainloopOrg"; fi - if [ -z "$CHAINLOOP_PROJECT" ]; then missing="$missing chainloopProject"; fi - if [ -z "$CHAINLOOP_WORKFLOW" ]; then missing="$missing chainloopWorkflow"; fi - if [ -n "$missing" ]; then - die "trace run mode needs an explicit attestation identity ('chainloop trace - run' ignores .chainloop.yml by design), but these kit args are empty:$missing - Supply them at launch, e.g. - --kit-arg chainloopOrg=my-org --kit-arg chainloopProject=my-project - or initialize the repo for persistent tracing with 'chainloop trace init'." - fi + chainloop trace init - echo "[chainloop-trace] Recording this session to Chainloop (trace run)" >&2 - echo "[chainloop-trace] org=$CHAINLOOP_ORG project=$CHAINLOOP_PROJECT workflow=$CHAINLOOP_WORKFLOW" >&2 + It writes .chainloop.yml (organization + projectName) and the + 'chainloop trace hook' entries in .claude/settings.json, which this kit + reads to know what to attest and when. Both must be committed - the + sandbox works on a clone, so uncommitted config does not reach it. - exec chainloop trace run \ - --org "$CHAINLOOP_ORG" \ - --project "$CHAINLOOP_PROJECT" \ - --workflow "$CHAINLOOP_WORKFLOW" \ - --claude \ - -- claude --dangerously-skip-permissions "$@" + Refusing to start rather than run a session that records nothing." + fi + + echo "[chainloop-trace] Repo initialized for chainloop trace - persistent mode" >&2 + echo "[chainloop-trace] identity from .chainloop.yml; attestation is pushed on 'git push'" >&2 + run_plain "$@" environment: - # v2 has no `environment.proxyManaged:` list - the sentinel is declared per - # credential as credentials[].apiKey.proxyManaged (see below). variables: # NOTE: HOME is deliberately NOT declared. The agent and the hook subprocess # MUST share it (transcript discovery is os.UserHomeDir()-based; a mismatch # silently loses usage/cost/tools), but sbx already sets HOME=/home/agent on # PID 1, so every child inherits it - verified in a plain `claude` sandbox. - # Mode selection - see the traceMode arg above for what each value does. - CHAINLOOP_TRACE_MODE: ${{ kit.args.traceMode }} - # `trace run` identity ONLY. Empty in persistent mode, where the identity - # comes from the repo's own .chainloop.yml. - CHAINLOOP_ORG: ${{ kit.args.chainloopOrg }} - CHAINLOOP_PROJECT: ${{ kit.args.chainloopProject }} - CHAINLOOP_WORKFLOW: ${{ kit.args.chainloopWorkflow }} # Self-hosted overrides only (defaults are the SaaS endpoints): # CHAINLOOP_CONTROL_PLANE_API: "api.cp.chainloop.dev:443" # CHAINLOOP_ARTIFACT_CAS_API: "api.cas.chainloop.dev:443" @@ -314,10 +260,6 @@ credentials: # block that a hand-written fork would have had to copy. Do NOT redeclare it - # named arrays merge by identity key (credentials[].service) and a duplicate # service across parent and child is an ERROR, not an override. - # - # (For reference, v2 collapses v1's four-part wiring - credentials.sources + - # network.serviceDomains + network.serviceAuth + environment.proxyManaged - - # into one entry per service, and apiKey.proxyManaged defaults to FALSE.) # --- Path A (NOT USABLE TODAY - verified v0.38.0, re-verified on the v0.39 line) # Proxy-managed injection of the Chainloop token, so the real secret never @@ -359,7 +301,7 @@ credentials: # scheme: bearer # --------------------------------------------------------------------------- -permissions: # v1: `network.allowedDomains:` +permissions: # Sandbox egress is deny-by-default. The Anthropic/Claude hosts come from the # parent (set union, deduplicated, parent order first); these are the Chainloop # additions. @@ -367,7 +309,7 @@ permissions: # v1: `network.allowedDomains:` allow: - "api.cp.chainloop.dev:443" # control plane - ALWAYS (attest + keyless signing CSR) - "api.cas.chainloop.dev:443" # CAS - only if the org CAS backend is external - - "api.app.chainloop.dev:443" # platform backend - the EE CLI also dials this + - "api.app.chainloop.dev:443" # platform backend - the CLI also dials this - "t.chainloop.dev:443" # CLI usage telemetry (PostHog) - current builds - "crb.chainloop.dev:443" # CLI usage telemetry (PostHog) - binaries released # before the endpoint moved. The host is compiled @@ -382,13 +324,13 @@ permissions: # v1: `network.allowedDomains:` - "buf.build:443" # Buf Schema Registry - `make api` / `buf generate` # resolve the buf.yaml module deps and the remote # plugins declared in the buf.gen.yaml files - - "dl.chainloop.dev:443" # EE CLI installer - - "chainloop-baafegchfnekdcde.z02.azurefd.net:443" # EE CLI download CDN + - "dl.chainloop.dev:443" # CLI installer + - "chainloop-baafegchfnekdcde.z02.azurefd.net:443" # CLI download CDN - "github.com:443" # git push over HTTPS in persistent mode - the # pre-push attestation egresses on push. Other # forges/self-hosted: add your own host here. -agentInstructions: # v1: `sandbox.aiFilename` + `agentContext:` +agentInstructions: # filename is inherited (CLAUDE.md). NOTE: `content` is a scalar, so this # REPLACES the parent's CLAUDE.md text (its CLAUDE_ENV_FILE / shell-completion # guidance) rather than appending to it. @@ -396,5 +338,5 @@ agentInstructions: # v1: `sandbox.aiFilename` + `agent ## Chainloop tracing This session is recorded by Chainloop trace. Tool/MCP usage and coding activity are attested and sent to Chainloop (as a CHAINLOOP_AI_CODING_SESSION - attestation) when the agent exits cleanly, or on `git push` when the - repository is initialized for persistent tracing. + attestation) when you `git push` from this sandbox. No push, no record - + so push before the sandbox is reclaimed. diff --git a/sbxenv.yaml b/sbxenv.yaml index f3bad9edd..d41d9d93e 100644 --- a/sbxenv.yaml +++ b/sbxenv.yaml @@ -73,10 +73,10 @@ args: agent: chainloop-trace-claude kits: - source: ${{ env.args.kitPath }} - # The kit declares these; see its spec.yaml for the full list. - # chainloopOrg/chainloopProject are deliberately absent: they exist only for - # `trace run` mode, and in persistent mode the identity comes from this - # repo's own .chainloop.yml. + # The kit declares these; see its spec.yaml for the full list. There is no + # mode or identity argument: the kit runs persistent tracing only and reads + # org/project/workflow from this repo's own committed .chainloop.yml. It + # refuses to start on a repo that has not had `chainloop trace init` run. args: # CAVEAT: the environment plan prints this in CLEARTEXT — verified, and # moving it off `env:` did not help; only a literal `secrets:` value is @@ -84,12 +84,6 @@ kits: # approval. Keep it out of shell history at least: # sbx env run --env-args-file ~/.config/chainloop/sbxenv-args chainloopToken: ${{ env.args.chainloopToken }} - # Force persistent rather than leaving it to auto-detection. On THIS repo - # auto resolves to persistent anyway, but pinning it means a detection - # miss fails loudly instead of falling back to `trace run` — whose - # teardown wipes .git/chainloop-trace/ and strips the committed chainloop - # hooks from .claude/settings.json. - traceMode: persistent # Sign commits and tags with the SSH key forwarded from your host's agent, so # the agent's commits carry your signature and not just your name. A `kind: From 37d616599f93c195770f0ff428cf71ab726f2e42 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Sun, 20 Sep 2026 10:23:29 +0200 Subject: [PATCH 6/8] refactor(sandbox-kit): trim the spec to what it declares (PFM-7373) Move the long-form rationale out of spec.yaml and leave short notes with pointers to the README, which already carries the same material under "Why nightly" and "Why the token is passed in". Removes the build-requirement banner, the gRPC/ALPN analysis, the commented-out proxy-managed credential block, the v1-to-v2 migration asides and the per-host debugging notes. No functional change: the egress allowlist, environment variables and wrapper behaviour are unchanged. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: 7da50f6b-428c-4749-82b5-562ae1ba890e --- devel/sandbox-kit/claude/spec.yaml | 224 ++++++----------------------- 1 file changed, 44 insertions(+), 180 deletions(-) diff --git a/devel/sandbox-kit/claude/spec.yaml b/devel/sandbox-kit/claude/spec.yaml index 5b99216e5..4607a8a3e 100644 --- a/devel/sandbox-kit/claude/spec.yaml +++ b/devel/sandbox-kit/claude/spec.yaml @@ -17,15 +17,8 @@ # sbx run --clone -e CHAINLOOP_TOKEN ./devel/sandbox-kit/claude # See devel/sandbox-kit/README.md for the sharp edges. # -# !!! BUILD REQUIREMENT - this kit needs an sbx build where `extends:` merges -# !!! ADDITIVELY. A child `setup:` block silently dropping the parent's -# !!! (docker/sbx-releases#415) takes Claude's trust-flag seeding, MCP gateway -# !!! registration and the ~/.claude/* volume ownership with it, so the sandbox -# !!! starts but cannot write the transcript `chainloop trace` reads: it attests -# !!! NOTHING, with no error. Known: <=v0.38.1 broken, nightly rc1-245 ok, -# !!! v0.39.0 stable broken (#415 regressed in the release), rc1-441 ok. -# !!! Check a build with: -# !!! sbx exec -- stat -c '%U:%G' /home/agent/.claude/projects # want agent:agent +# Needs an sbx build whose `extends:` merges additively; on an affected build the +# sandbox starts but records nothing. See "Why nightly" in the README. schemaVersion: "2" kind: sandbox # REQUIRED: only kind:sandbox may set an entrypoint name: chainloop-trace-claude @@ -39,28 +32,11 @@ description: >- for `chainloop trace`: identity comes from its committed .chainloop.yml and the attestation is pushed on `git push`. Fork of the built-in `claude` agent. -# Everything the caller has to supply arrives as a kit ARGUMENT, so this file -# holds no secrets and is committed as-is. `sbx kit validate ./kit` names any -# argument it still needs. -# -# ONE MODE ON PURPOSE - persistent, i.e. `chainloop trace init` has already -# written organization/projectName (and optionally the workflow, else -# "ai-coding-session") into the repo's .chainloop.yml, and the managed pre-push -# hook reads them from there. The kit contributes NOTHING but the token, which is -# why there are no org/project/workflow arguments. -# -# The other mode the CLI offers, `chainloop trace run`, is deliberately NOT -# supported here: -# - it "ignores .chainloop.yml entirely. The attestation identity must come -# from the --org, --project, --workflow flags (mandatory)" -# (`chainloop trace run --help`), so it needs three arguments that are dead -# weight for every repo this kit is actually pointed at; -# - its teardown wipes .git/chainloop-trace/ and strips the committed agent -# hooks from .claude/settings.json - destructive on a trace-initialized repo, -# which is the only kind this kit accepts. -# The trade-off that buys: a session whose work is never pushed attests nothing. -# That is intended - unpushed work is discarded work, and the sandbox is -# disposable. Push from inside the sandbox before it is reclaimed. +# No secrets live here - everything the caller supplies is a kit argument, so +# this file is committed as-is. There is no mode or identity argument: the kit +# does persistent tracing only and reads org/project/workflow from the repo's +# committed .chainloop.yml. See the README for why `chainloop trace run` is not +# offered. args: chainloopToken: default: "" @@ -86,31 +62,18 @@ args: worthwhile for self-hosted. NOTE its [auth] token is your personal login session: short-lived (~days) and your full identity. Prefer chainloopToken for anything unattended. -# Inherit the built-in `claude` agent instead of restating it. This kit then -# declares ONLY the Chainloop delta; everything below comes for free and stays in -# step with sbx upgrades instead of drifting in a hand-copied spec: -# - sandbox.image (no tag to verify or bump) -# - the `anthropic` credential: proxy-injected x-api-key AND the OAuth block -# - Anthropic/Claude egress (api.anthropic.com, claude.com, downloads.claude.ai, -# mcp-proxy.anthropic.com, platform.claude.com, bridge.claudeusercontent.com) -# - IS_SANDBOX=1 -# - ~/.claude.json trust+onboarding flags, ~/.claude/settings.json seeding, and -# `claude mcp add mcp-gateway` (MCP is invisible to the session without it) -# - the persistent ~/.claude/{projects,sessions,todos,shell-snapshots,statsig} -# volumes - projects/ holds the transcript `chainloop trace` reads -# - agentInstructions.filename: CLAUDE.md +# Inherit the built-in `claude` agent - its image, Anthropic credential and +# egress, MCP gateway registration and ~/.claude volumes - and declare only the +# Chainloop delta below. extends: claude sandbox: - # REPLACES the parent's [claude, --dangerously-skip-permissions] -> the wrap - # point. The wrapper re-adds that flag when it execs claude. + # Replaces the parent's entrypoint; the wrapper re-adds its flags. entrypoint: ["/home/agent/.local/bin/cl-trace-wrap.sh"] setup: install: - # Install the chainloop CLI. The installer requires the target dir to exist, - # so create it first. Run as root so we can write to /usr/local/bin (hooks - # invoke `chainloop` by bare name -> must be on PATH). + # The git hooks invoke `chainloop` by bare name, so it has to be on PATH. - command: >- mkdir -p /usr/local/bin && curl -sfL https://dl.chainloop.dev/cli/install.sh | bash -s -- --path /usr/local/bin && @@ -128,10 +91,8 @@ setup: exec claude --dangerously-skip-permissions "$@" } - # Every path that would hand back an UNTRACED agent is fatal instead. - # This kit exists to make a session provable, and a sandbox that quietly - # records nothing is the one outcome worse than not starting: you find - # out only when the attestation you expected is not there. + # Anything that would hand back an untraced agent is fatal: a sandbox + # that quietly records nothing is worse than one that refuses to start. die() { echo "[chainloop-trace] ERROR: $1" >&2 exit 1 @@ -142,15 +103,8 @@ setup: (check egress to dl.chainloop.dev). Refusing to start an untraced agent." fi - # Adopt a mounted host config.toml when one was pointed at. sbx mounts an - # extra read-only workspace at the SAME absolute path it has on the host, - # which is NOT where the CLI looks: `chainloop config view` inside the - # sandbox reports /home/agent/.config/chainloop/config.toml (Linux XDG), - # verified. Copy rather than pass --config, because in persistent mode the - # managed git hooks invoke `chainloop` as their own processes and a flag - # from this wrapper would never reach them. - # Deliberately not named CHAINLOOP_CONFIG: the CLI's viper prefix is - # CHAINLOOP, so that name could bind to its own --config flag. + # Adopt a mounted host config.toml. Copied into place rather than passed + # with --config, so the git hooks find it in their own processes too. if [ -n "$CL_TRACE_CONFIG_SOURCE" ]; then if [ ! -r "$CL_TRACE_CONFIG_SOURCE" ]; then die "chainloopConfig points at '$CL_TRACE_CONFIG_SOURCE', which is not @@ -163,11 +117,8 @@ setup: echo "[chainloop-trace] Adopted chainloop config from $CL_TRACE_CONFIG_SOURCE" >&2 fi - # Something has to authenticate the attestation. An explicit token wins - # over a config-file login session - the CLI prefers an exported - # CHAINLOOP_TOKEN and logs "Both user credentials and $CHAINLOOP_TOKEN - # set. Ignoring user credentials." (app/cli/cmd/root.go) - but with - # neither, nothing can be pushed, so refuse rather than record nothing. + # Without credentials nothing can be pushed, so refuse rather than + # record nothing. A token wins over a config-file login session. if [ -z "$CHAINLOOP_TOKEN" ] && [ ! -r "$HOME/.config/chainloop/config.toml" ]; then die "no Chainloop credentials. Pass an org-scoped API token with --kit-arg chainloopToken= @@ -175,11 +126,8 @@ setup: --kit-arg chainloopConfig=" fi - # Persistent tracing is the only mode: the repo must already be - # initialized, and the identity comes from its committed .chainloop.yml. - # (Every kit arg has a default, so the vars above are always defined and - # bare $VAR is safe under `set -u`; the validator rejects shell - # default-expansion inside setup.files content.) + # Persistent tracing only: the repo must already be initialized, and the + # identity comes from its committed .chainloop.yml. repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") detected=0 for f in "$repo_root/.chainloop.yml" "$repo_root/.chainloop.yaml"; do @@ -211,129 +159,45 @@ setup: environment: variables: - # NOTE: HOME is deliberately NOT declared. The agent and the hook subprocess - # MUST share it (transcript discovery is os.UserHomeDir()-based; a mismatch - # silently loses usage/cost/tools), but sbx already sets HOME=/home/agent on - # PID 1, so every child inherits it - verified in a plain `claude` sandbox. - # Self-hosted overrides only (defaults are the SaaS endpoints): - # CHAINLOOP_CONTROL_PLANE_API: "api.cp.chainloop.dev:443" - # CHAINLOOP_ARTIFACT_CAS_API: "api.cas.chainloop.dev:443" - # - # Path B: the REAL org-scoped API token is set directly in the sandbox env, - # from the required `chainloopToken` arg - so no secret is stored in this - # file and it is committed as-is. - # Do NOT use `sbx secret set-custom` / proxy-managed header injection for this - # token (see the commented-out Path A block under `credentials:`): injection - # requires the proxy to TERMINATE TLS, and the intercepted path does not - # negotiate the `h2` ALPN - which is exactly what grpc-go >=1.67 refuses. + # HOME is deliberately not declared: the agent and the hook subprocess must + # share it, and sbx already sets it on PID 1. CHAINLOOP_TOKEN: ${{ kit.args.chainloopToken }} - # Path (inside the sandbox) of a mounted host config.toml for the wrapper to - # adopt. Not CHAINLOOP_-prefixed on purpose - see the wrapper's note. + # Path inside the sandbox of a mounted host config.toml for the wrapper to + # adopt. Not CHAINLOOP_-prefixed, so it cannot bind to the CLI's own flags. CL_TRACE_CONFIG_SOURCE: ${{ kit.args.chainloopConfig }} - # - # CRITICAL (gRPC vs the egress proxy): `chainloop trace` talks to the control - # plane over gRPC (HTTP/2 + ALPN). sbx sets HTTP(S)_PROXY=gateway.docker.internal:3128 - # and grpc-go honors it, dialing via HTTP CONNECT. The proxy handles a host in - # one of two ways, and only one of them works for gRPC (probe any host with - # `curl -sv --http2 --proxy "$HTTPS_PROXY" https:///` and read the issuer): - # TUNNEL - no credential targets the host. TLS is end-to-end (real - # Let's Encrypt cert), ALPN reaches the server, `h2` negotiated. - # INTERCEPTED - some credential targets the host, so the proxy terminates TLS - # to rewrite headers (issuer "Docker Sandboxes Proxy CA"). It - # answers `ALPN: server did not agree on a protocol` and drops - # to HTTP/1.1; grpc-go >=1.67 aborts with "missing selected ALPN - # property" and NO attestation is sent. - # A host flips to INTERCEPTED the moment ANY credential targets it - a kit - # apiKey.inject[].domain, OR a host-side `sbx secret set-custom --host - # api.cp.chainloop.dev --env CHAINLOOP_TOKEN`, which is easy to add from outside - # this repo and silently breaks tracing (verified on sbx v0.38.0: same kit, - # same empty token, tunnel before the custom secret existed and intercepted - # after; re-verified unchanged on v0.39.0-rc1-441). Bypassing the proxy for the - # Chainloop hosts makes tracing immune to that decision; egress is still - # enforced by sbx's allowlist. Keep localhost / gateway.docker.internal, which - # is what sbx's own default NO_PROXY carries. + # The Chainloop hosts MUST bypass the egress proxy: `chainloop trace` speaks + # gRPC, and the proxy's intercepted path cannot negotiate h2, so anything that + # routes these hosts through it silently breaks attestation. See the README. NO_PROXY: "localhost,127.0.0.1,::1,gateway.docker.internal,api.cp.chainloop.dev,api.cas.chainloop.dev,api.app.chainloop.dev" no_proxy: "localhost,127.0.0.1,::1,gateway.docker.internal,api.cp.chainloop.dev,api.cas.chainloop.dev,api.app.chainloop.dev" credentials: - # No `anthropic` entry: it is inherited from the parent, including the OAuth - # block that a hand-written fork would have had to copy. Do NOT redeclare it - - # named arrays merge by identity key (credentials[].service) and a duplicate - # service across parent and child is an ERROR, not an override. - - # --- Path A (NOT USABLE TODAY - verified v0.38.0, re-verified on the v0.39 line) - # Proxy-managed injection of the Chainloop token, so the real secret never - # enters the VM. Uncomment this entry, drop the CHAINLOOP_TOKEN variable above - # and the chainloopToken arg, and store the secret on the host with - # sbx secret set-custom --host api.cp.chainloop.dev --env CHAINLOOP_TOKEN - # - # Why it cannot be enabled yet - TESTED END-TO-END, not inferred: injection only - # works on hosts the proxy INTERCEPTS (it must terminate TLS to rewrite the - # header), and the intercepted path answers `ALPN: server did not agree on a - # protocol`, dropping to HTTP/1.1. grpc-go >=1.67 then aborts with "missing - # selected ALPN property" and NO attestation is sent. - # - # !! This also applies to `sbx secret set-custom --host api.cp.chainloop.dev - # !! --env CHAINLOOP_TOKEN`, which is the same mechanism by another route (it - # !! substitutes a `sbx-cs-...` placeholder in outbound headers). A global custom - # !! secret for the Chainloop hosts is INERT only because this kit sets - # !! CHAINLOOP_TOKEN itself, which suppresses the binding. Stop setting the token - # !! here and the placeholder goes live, the hosts flip to the intercepted path, - # !! and tracing dies with the ALPN error. Verified on sbx v0.38.0: - # !! CHAINLOOP_TOKEN=sbx-cs-... -> issuer "Docker Sandboxes Proxy CA", HTTP/1.x - # !! kit-set CHAINLOOP_TOKEN -> issuer "Let's Encrypt", h2, gRPC works - # - # Re-test with: - # curl -sv --http2 --proxy "$HTTPS_PROXY" https://api.cp.chainloop.dev/ - # and enable this once the issuer is "Docker Sandboxes Proxy CA" AND the server - # still agrees on h2. + # No `anthropic` entry: it is inherited from the parent. Do NOT redeclare it - + # a duplicate credentials[].service across parent and child is an ERROR. # - # - service: chainloop - # apiKey: - # name: CHAINLOOP_TOKEN - # proxyManaged: true - # inject: - # - domain: api.cp.chainloop.dev - # scheme: bearer # v2 shorthand for Authorization: Bearer %s - # - domain: api.cas.chainloop.dev - # scheme: bearer - # - domain: api.app.chainloop.dev - # scheme: bearer - # --------------------------------------------------------------------------- + # Proxy-managed injection of the Chainloop token is not usable here: it needs + # the proxy to terminate TLS, which breaks the kit's gRPC traffic. See the + # README for the full reasoning and how to re-test it. permissions: - # Sandbox egress is deny-by-default. The Anthropic/Claude hosts come from the - # parent (set union, deduplicated, parent order first); these are the Chainloop - # additions. + # Egress is deny-by-default; the Anthropic/Claude hosts come from the parent. network: allow: - - "api.cp.chainloop.dev:443" # control plane - ALWAYS (attest + keyless signing CSR) - - "api.cas.chainloop.dev:443" # CAS - only if the org CAS backend is external - - "api.app.chainloop.dev:443" # platform backend - the CLI also dials this - - "t.chainloop.dev:443" # CLI usage telemetry (PostHog) - current builds - - "crb.chainloop.dev:443" # CLI usage telemetry (PostHog) - binaries released - # before the endpoint moved. The host is compiled - # in, so an older tag checked out in a sandbox - # still reaches for it. Commands succeed either - # way, but a connection that is dropped rather - # than refused costs each tracked command a - # couple of seconds waiting on the send deadline. - - "timestamp.digicert.com:80" # RFC 3161 timestamp authority (plain HTTP, signed response) - # used during attestation signing; host comes from the - # control plane's signing options - - "buf.build:443" # Buf Schema Registry - `make api` / `buf generate` - # resolve the buf.yaml module deps and the remote - # plugins declared in the buf.gen.yaml files + - "api.cp.chainloop.dev:443" # control plane + - "api.cas.chainloop.dev:443" # CAS, when the org backend is external + - "api.app.chainloop.dev:443" # platform backend + - "t.chainloop.dev:443" # CLI telemetry + - "crb.chainloop.dev:443" # CLI telemetry, older binaries + - "timestamp.digicert.com:80" # RFC 3161 timestamp authority, used when signing + - "buf.build:443" # Buf Schema Registry, for `make api` - "dl.chainloop.dev:443" # CLI installer - "chainloop-baafegchfnekdcde.z02.azurefd.net:443" # CLI download CDN - - "github.com:443" # git push over HTTPS in persistent mode - the - # pre-push attestation egresses on push. Other - # forges/self-hosted: add your own host here. + - "github.com:443" # git push - the attestation egresses here. + # Other forges: add your own host. agentInstructions: - # filename is inherited (CLAUDE.md). NOTE: `content` is a scalar, so this - # REPLACES the parent's CLAUDE.md text (its CLAUDE_ENV_FILE / shell-completion - # guidance) rather than appending to it. + # filename is inherited (CLAUDE.md). `content` is a scalar, so this REPLACES + # the parent's text rather than appending to it. content: | ## Chainloop tracing This session is recorded by Chainloop trace. Tool/MCP usage and coding From 8e0414c25254fec3547f30453b1376ce6c811934 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Sun, 20 Sep 2026 10:34:17 +0200 Subject: [PATCH 7/8] refactor(sandbox-kit): publish per kit, and only on a version change (PFM-7373) Review follow-ups. Publish every kit under devel/sandbox-kit rather than the hardcoded claude one: a discover job derives the matrix from the directories that hold a spec.yaml, so a new kit needs no workflow edit. Narrow the trigger to devel/sandbox-kit/*/spec.yaml. The previous devel/sandbox-kit/** also matched the README, so a docs edit republished the released tag with fresh content under the same immutable version. Guard the same hazard from the other side by skipping a version already present in the registry, since the trigger still fires on spec edits that leave the version alone. Make a zero-match glob fatal in the bump script. It previously ran from the invocation directory and swallowed a miss, so a release run from the wrong place would leave every spec at the old version and publish nothing, with no error. Move checkout and the version read ahead of the tooling installs so a missing version fails in seconds rather than after a 95MB download. Drop the wrapper's run_plain indirection and the detected flag left over from two-mode dispatch, along with the last stale references to the removed trace mode. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: 7da50f6b-428c-4749-82b5-562ae1ba890e --- .github/workflows/package_sandbox_kit.yaml | 90 ++++++++++++++----- .../utils/bump-chart-and-dagger-version.sh | 15 ++-- devel/sandbox-kit/README.md | 2 +- devel/sandbox-kit/claude/spec.yaml | 25 ++---- sbxenv.yaml | 2 +- 5 files changed, 86 insertions(+), 48 deletions(-) diff --git a/.github/workflows/package_sandbox_kit.yaml b/.github/workflows/package_sandbox_kit.yaml index 267a6fd41..fb6eab2d4 100644 --- a/.github/workflows/package_sandbox_kit.yaml +++ b/.github/workflows/package_sandbox_kit.yaml @@ -1,21 +1,50 @@ name: Package Sandbox Kit on: - # Only push the kit if the kit sources have changed. The release bump PR - # rewrites each spec.yaml `version:`, so merging it is what triggers a publish - # — the same shape as package_chart.yaml reacting to deployment/chainloop/** + # Only the specs, not the README beside them: the release bump PR rewrites each + # spec.yaml `version:`, and that is what should trigger a publish. push: branches: - main paths: - - devel/sandbox-kit/** + - devel/sandbox-kit/*/spec.yaml permissions: read-all jobs: + # Every directory under devel/sandbox-kit/ holding a spec.yaml is a kit, so + # adding one needs no change here. + discover: + name: Discover kits + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + kits: ${{ steps.find.outputs.kits }} + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false + - id: find + run: | + kits=$(find devel/sandbox-kit -mindepth 2 -maxdepth 2 -name spec.yaml -printf '%h\n' \ + | sed 's|.*/||' | sort | jq -Rsc 'split("\n")[:-1]') + if [[ "${kits}" == "[]" ]]; then + echo "::error::no kits found under devel/sandbox-kit" + exit 1 + fi + echo "kits=${kits}" >> $GITHUB_OUTPUT + echo "Publishing: ${kits}" + package: - name: Package and push Sandbox Kit + name: Package and push ${{ matrix.kit }} + needs: discover runs-on: ubuntu-latest + strategy: + # One kit's failure must not cancel the others mid-publish. + fail-fast: false + matrix: + kit: ${{ fromJSON(needs.discover.outputs.kits) }} permissions: contents: read id-token: write # Docker Hub OIDC login, SLSA provenance and keyless kit signing @@ -25,9 +54,25 @@ jobs: # Docker Sandboxes ships Linux packages only on tagged releases, not on # nightly, so this is pinned to a stable tag and bumped by hand. SBX_VERSION: "v0.43.0" - KIT_DIR: "devel/sandbox-kit/claude" - KIT_REPO: "docker.io/chainloop/sbx-kit-claude" + KIT_DIR: "devel/sandbox-kit/${{ matrix.kit }}" + KIT_REPO: "docker.io/chainloop/sbx-kit-${{ matrix.kit }}" steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false + + - name: Read kit version + id: kit_version + run: | + # The kit's own spec.yaml is the source of truth; bump-chart-and-dagger-version.sh + # keeps it in step with the chart's appVersion on every release. + kit_version=$(yq -r '.version' "${KIT_DIR}/spec.yaml") + if [[ -z "${kit_version}" || "${kit_version}" == "null" ]]; then + echo "::error::${KIT_DIR}/spec.yaml declares no version:" + exit 1 + fi + echo "kit_version=${kit_version}" >> $GITHUB_OUTPUT + - name: Install Chainloop # Deliberately NOT `curl ... | bash`: this job holds an OIDC token that can # mint Docker Hub credentials and sign artifacts, so the installer is @@ -64,28 +109,29 @@ jobs: with: username: chainloop - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - persist-credentials: false - - - name: Read kit version - id: kit_version + - name: Skip if this version is already published + id: published + env: + KIT_VERSION: ${{ steps.kit_version.outputs.kit_version }} run: | - # The kit's own spec.yaml is the source of truth; bump-chart-and-dagger-version.sh - # keeps it in step with the chart's appVersion on every release. - kit_version=$(yq -r '.version' "${KIT_DIR}/spec.yaml") - if [[ -z "${kit_version}" || "${kit_version}" == "null" ]]; then - echo "::error::${KIT_DIR}/spec.yaml declares no version:" - exit 1 + # The trigger fires on any spec.yaml change, not only a version bump, so + # a mid-cycle edit must not overwrite an already-signed, already-attested + # immutable tag with fresh content. + if sbx kit inspect "${KIT_REPO}:${KIT_VERSION}" >/dev/null 2>&1; then + echo "::notice::${KIT_REPO}:${KIT_VERSION} is already published, nothing to do" + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT fi - echo "kit_version=${kit_version}" >> $GITHUB_OUTPUT - name: Validate kit + if: steps.published.outputs.skip == 'false' # Fails loudly here rather than halfway through a push if the pinned sbx # release does not understand something the spec declares. run: sbx kit validate "./${KIT_DIR}" - name: Add Attestation (Sandbox Kit) and Push Kit + if: steps.published.outputs.skip == 'false' run: | # KIT_VERSION arrives through env, not ${{ }} interpolation, so the value # is never expanded into this script's source. @@ -114,12 +160,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Finish and Record Attestation - if: ${{ success() }} + if: ${{ success() && steps.published.outputs.skip == 'false' }} run: | chainloop attestation push - name: Mark attestation as failed - if: ${{ failure() }} + if: ${{ failure() && steps.published.outputs.skip == 'false' }} run: | chainloop attestation reset diff --git a/.github/workflows/utils/bump-chart-and-dagger-version.sh b/.github/workflows/utils/bump-chart-and-dagger-version.sh index 1501c6f02..a0b4569d0 100755 --- a/.github/workflows/utils/bump-chart-and-dagger-version.sh +++ b/.github/workflows/utils/bump-chart-and-dagger-version.sh @@ -60,11 +60,14 @@ if [[ -n "${platform_version}" && "${platform_version}" != "null" ]]; then fi ## Update the Docker Sandboxes kit versions -# Each kit declares the Chainloop release whose CLI it ships, so they track semVer -# like appVersion does. The path is fixed rather than an argument because this is a -# directory of kits that grows, and `schemaVersion:` is left alone by the ^version anchor. -for kit_spec in devel/sandbox-kit/*/spec.yaml; do - [ -e "${kit_spec}" ] || continue - sed -i "s#^version:.*#version: ${semVer}#g" "${kit_spec}" +# Each kit declares the Chainloop release it belongs to, tracking semVer like +# appVersion does. `schemaVersion:` is left alone by the ^version anchor. +# Matching nothing is an error, not a no-op: a silent skip here would leave the +# specs at the old version and the publish workflow would never fire. +shopt -s nullglob +kit_specs=(devel/sandbox-kit/*/spec.yaml) +[ "${#kit_specs[@]}" -gt 0 ] || die "no kit specs found under devel/sandbox-kit (run from the repo root)" +for kit_spec in "${kit_specs[@]}"; do + sed -i "s#^version:.*#version: ${semVer}#" "${kit_spec}" done diff --git a/devel/sandbox-kit/README.md b/devel/sandbox-kit/README.md index f97573561..6345bdff3 100644 --- a/devel/sandbox-kit/README.md +++ b/devel/sandbox-kit/README.md @@ -45,7 +45,7 @@ inside the sandbox before it is reclaimed. ### 1. Through the environment file -The repo's `sbxenv.yaml` declares the agent, the kit, the clone-mode workspace and the trace mode, so the +The repo's `sbxenv.yaml` declares the agent, the kit and the clone-mode workspace, so the only thing left to pass is the token: ```bash diff --git a/devel/sandbox-kit/claude/spec.yaml b/devel/sandbox-kit/claude/spec.yaml index 4607a8a3e..9e9a2821c 100644 --- a/devel/sandbox-kit/claude/spec.yaml +++ b/devel/sandbox-kit/claude/spec.yaml @@ -22,9 +22,10 @@ schemaVersion: "2" kind: sandbox # REQUIRED: only kind:sandbox may set an entrypoint name: chainloop-trace-claude -# Tracks the Chainloop release this kit ships the CLI of. Bumped automatically on -# release by .github/workflows/utils/bump-chart-and-dagger-version.sh, alongside the -# chart's appVersion - do not edit by hand. +# The Chainloop release this kit belongs to. Bumped automatically on release by +# .github/workflows/utils/bump-chart-and-dagger-version.sh, alongside the chart's +# appVersion - do not edit by hand. NOTE the install step below is not pinned to +# it, so it is a release marker, not a statement about the CLI inside. version: v1.109.0 displayName: Claude Code (Chainloop-traced) description: >- @@ -87,10 +88,6 @@ setup: #!/usr/bin/env bash set -euo pipefail - run_plain() { - exec claude --dangerously-skip-permissions "$@" - } - # Anything that would hand back an untraced agent is fatal: a sandbox # that quietly records nothing is worse than one that refuses to start. die() { @@ -129,16 +126,8 @@ setup: # Persistent tracing only: the repo must already be initialized, and the # identity comes from its committed .chainloop.yml. repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") - detected=0 - for f in "$repo_root/.chainloop.yml" "$repo_root/.chainloop.yaml"; do - if [ -f "$f" ] && grep -qE '^[[:space:]]*projectName:[[:space:]]*["'"'"']?[A-Za-z0-9]' "$f" \ - && grep -q "chainloop trace hook" "$repo_root/.claude/settings.json" 2>/dev/null; then - detected=1 - break - fi - done - - if [ "$detected" != 1 ]; then + if ! grep -qsE '^[[:space:]]*projectName:[[:space:]]*["'"'"']?[A-Za-z0-9]' "$repo_root"/.chainloop.y*ml \ + || ! grep -qs "chainloop trace hook" "$repo_root/.claude/settings.json"; then die "this repository is not initialized for chainloop trace. Run this in the repository first, then start the sandbox again: @@ -155,7 +144,7 @@ setup: echo "[chainloop-trace] Repo initialized for chainloop trace - persistent mode" >&2 echo "[chainloop-trace] identity from .chainloop.yml; attestation is pushed on 'git push'" >&2 - run_plain "$@" + exec claude --dangerously-skip-permissions "$@" environment: variables: diff --git a/sbxenv.yaml b/sbxenv.yaml index d41d9d93e..562c76112 100644 --- a/sbxenv.yaml +++ b/sbxenv.yaml @@ -112,7 +112,7 @@ workspace: # devel/sandbox-kit/README.md. # NOTE — deliberately NOT declared here: -# env: nothing left to set. The Chainloop token and the mode reach the +# env: nothing left to set. The Chainloop token reaches the # sandbox as KIT arguments (above), not as environment variables, so # the kit owns its own configuration surface. Path B still applies: # the kit turns chainloopToken into CHAINLOOP_TOKEN inside the VM, From 5efc6446f163d32d087fc5d142aaed02599c58b0 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Sun, 20 Sep 2026 19:05:01 +0200 Subject: [PATCH 8/8] fix(sandbox-kit): publish on a version change, not any spec edit (PFM-7373) Review follow-ups on PR 3455. Select kits in the discover job by diffing each spec's `version:` across the pushed range, and drop the registry-existence guard that replaced. The guard skipped the whole publish block, so a run whose version push succeeded but whose `latest` push or attestation failed could never be reconciled by a re-run; selecting on the version change instead leaves a re-run of that same push selecting the kit and completing the work, while a comment-only spec edit selects nothing. Spell out the two config filenames the wrapper accepts. The .chainloop.y*ml glob also matched files Chainloop never reads, such as .chainloop.yolo.ml, which would let the sandbox start without the tracing it promises. Restore the proxy re-test probe next to the credentials note, which pointed at a README section carrying the reasoning but not the procedure. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: 7da50f6b-428c-4749-82b5-562ae1ba890e --- .github/workflows/package_sandbox_kit.yaml | 65 ++++++++++++---------- devel/sandbox-kit/claude/spec.yaml | 12 +++- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/.github/workflows/package_sandbox_kit.yaml b/.github/workflows/package_sandbox_kit.yaml index fb6eab2d4..d39427f51 100644 --- a/.github/workflows/package_sandbox_kit.yaml +++ b/.github/workflows/package_sandbox_kit.yaml @@ -15,7 +15,7 @@ jobs: # Every directory under devel/sandbox-kit/ holding a spec.yaml is a kit, so # adding one needs no change here. discover: - name: Discover kits + name: Discover kits to publish runs-on: ubuntu-latest permissions: contents: read @@ -25,20 +25,46 @@ jobs: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: persist-credentials: false + fetch-depth: 0 # needs the pushed range to diff each spec's version - id: find + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} run: | - kits=$(find devel/sandbox-kit -mindepth 2 -maxdepth 2 -name spec.yaml -printf '%h\n' \ - | sed 's|.*/||' | sort | jq -Rsc 'split("\n")[:-1]') - if [[ "${kits}" == "[]" ]]; then - echo "::error::no kits found under devel/sandbox-kit" - exit 1 - fi - echo "kits=${kits}" >> $GITHUB_OUTPUT - echo "Publishing: ${kits}" + # Publish a kit only when its `version:` actually changed in this push. + # Editing a comment in a spec must not republish a released tag under a + # version that already means something, and a re-run of a partly failed + # publish still selects the kit, so `latest` and the attestation get + # reconciled rather than skipped. + kits=() + for spec in devel/sandbox-kit/*/spec.yaml; do + [ -e "${spec}" ] || continue + kit=$(basename "$(dirname "${spec}")") + new=$(yq -r '.version // ""' "${spec}") + if [[ -z "${new}" || "${new}" == "null" ]]; then + echo "::error::${spec} declares no version:" + exit 1 + fi + # An unknown or absent `before` (new branch, force push) counts as changed. + old="" + if git cat-file -e "${BEFORE}:${spec}" 2>/dev/null; then + old=$(git show "${BEFORE}:${spec}" | yq -r '.version // ""') + fi + if [[ "${new}" != "${old}" ]]; then + echo "${kit}: ${old:-} -> ${new}" + kits+=("${kit}") + else + echo "${kit}: unchanged at ${new}, skipping" + fi + done + printf '%s\n' "${kits[@]+"${kits[@]}"}" | jq -Rsc 'split("\n") | map(select(length > 0))' \ + | sed 's/^/kits=/' >> $GITHUB_OUTPUT package: name: Package and push ${{ matrix.kit }} needs: discover + # An empty matrix vector is an error, not a skip, so guard the whole job. + if: needs.discover.outputs.kits != '[]' runs-on: ubuntu-latest strategy: # One kit's failure must not cancel the others mid-publish. @@ -109,29 +135,12 @@ jobs: with: username: chainloop - - name: Skip if this version is already published - id: published - env: - KIT_VERSION: ${{ steps.kit_version.outputs.kit_version }} - run: | - # The trigger fires on any spec.yaml change, not only a version bump, so - # a mid-cycle edit must not overwrite an already-signed, already-attested - # immutable tag with fresh content. - if sbx kit inspect "${KIT_REPO}:${KIT_VERSION}" >/dev/null 2>&1; then - echo "::notice::${KIT_REPO}:${KIT_VERSION} is already published, nothing to do" - echo "skip=true" >> $GITHUB_OUTPUT - else - echo "skip=false" >> $GITHUB_OUTPUT - fi - - name: Validate kit - if: steps.published.outputs.skip == 'false' # Fails loudly here rather than halfway through a push if the pinned sbx # release does not understand something the spec declares. run: sbx kit validate "./${KIT_DIR}" - name: Add Attestation (Sandbox Kit) and Push Kit - if: steps.published.outputs.skip == 'false' run: | # KIT_VERSION arrives through env, not ${{ }} interpolation, so the value # is never expanded into this script's source. @@ -160,12 +169,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Finish and Record Attestation - if: ${{ success() && steps.published.outputs.skip == 'false' }} + if: ${{ success() }} run: | chainloop attestation push - name: Mark attestation as failed - if: ${{ failure() && steps.published.outputs.skip == 'false' }} + if: ${{ failure() }} run: | chainloop attestation reset diff --git a/devel/sandbox-kit/claude/spec.yaml b/devel/sandbox-kit/claude/spec.yaml index 9e9a2821c..779cd8eb8 100644 --- a/devel/sandbox-kit/claude/spec.yaml +++ b/devel/sandbox-kit/claude/spec.yaml @@ -126,7 +126,10 @@ setup: # Persistent tracing only: the repo must already be initialized, and the # identity comes from its committed .chainloop.yml. repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") - if ! grep -qsE '^[[:space:]]*projectName:[[:space:]]*["'"'"']?[A-Za-z0-9]' "$repo_root"/.chainloop.y*ml \ + # The two filenames the CLI reads, spelled out: a glob here would also + # accept files Chainloop never looks at, letting the sandbox start untraced. + if ! { grep -qsE '^[[:space:]]*projectName:[[:space:]]*["'"'"']?[A-Za-z0-9]' "$repo_root/.chainloop.yml" \ + || grep -qsE '^[[:space:]]*projectName:[[:space:]]*["'"'"']?[A-Za-z0-9]' "$repo_root/.chainloop.yaml"; } \ || ! grep -qs "chainloop trace hook" "$repo_root/.claude/settings.json"; then die "this repository is not initialized for chainloop trace. @@ -165,8 +168,11 @@ credentials: # a duplicate credentials[].service across parent and child is an ERROR. # # Proxy-managed injection of the Chainloop token is not usable here: it needs - # the proxy to terminate TLS, which breaks the kit's gRPC traffic. See the - # README for the full reasoning and how to re-test it. + # the proxy to terminate TLS, which breaks the kit's gRPC traffic (see the + # README). Re-test with: + # curl -sv --http2 --proxy "$HTTPS_PROXY" https://api.cp.chainloop.dev/ + # and reconsider once the issuer is "Docker Sandboxes Proxy CA" AND h2 is + # still negotiated. permissions: # Egress is deny-by-default; the Anthropic/Claude hosts come from the parent.