diff --git a/.github/workflows/package_sandbox_kit.yaml b/.github/workflows/package_sandbox_kit.yaml new file mode 100644 index 000000000..d39427f51 --- /dev/null +++ b/.github/workflows/package_sandbox_kit.yaml @@ -0,0 +1,184 @@ +name: Package Sandbox Kit + +on: + # 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/*/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 to publish + 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 + fetch-depth: 0 # needs the pushed range to diff each spec's version + - id: find + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + run: | + # 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. + 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 + 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/${{ 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 + # 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 -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: | + 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 + + # 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: chainloop + + - 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 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 + + # 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 + + # 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: + 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 }} + + - 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..a0b4569d0 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,15 @@ 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 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 13ab2dd62..6345bdff3 100644 --- a/devel/sandbox-kit/README.md +++ b/devel/sandbox-kit/README.md @@ -6,7 +6,9 @@ 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 +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 running list of upstream Docker bugs. @@ -31,12 +33,19 @@ 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 -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 @@ -51,7 +60,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 +70,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" ``` @@ -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 @@ -82,7 +91,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 +120,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 +176,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/claude/spec.yaml b/devel/sandbox-kit/claude/spec.yaml new file mode 100644 index 000000000..779cd8eb8 --- /dev/null +++ b/devel/sandbox-kit/claude/spec.yaml @@ -0,0 +1,201 @@ +# 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. +# +# 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 +# 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: >- + 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. + +# 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: "" + description: >- + Chainloop org-scoped API token (chainloop organization api-token create). + 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 + config-file login session and logs "Both user credentials and + $CHAINLOOP_TOKEN set. Ignoring user credentials." (app/cli/cmd/root.go). + chainloopConfig: + default: "" + description: >- + Absolute path INSIDE the sandbox to a chainloop config.toml to adopt - + 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 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. +# 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 entrypoint; the wrapper re-adds its flags. + entrypoint: ["/home/agent/.local/bin/cl-trace-wrap.sh"] + +setup: + install: + # 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 && + chainloop version + user: "0" + description: Install the chainloop CLI on PATH + files: + - path: /home/agent/.local/bin/cl-trace-wrap.sh + mode: "0755" + content: | + #!/usr/bin/env bash + set -euo pipefail + + # 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 + } + + if ! command -v chainloop >/dev/null 2>&1; then + die "chainloop CLI not on PATH - the kit's install step did not complete + (check egress to dl.chainloop.dev). Refusing to start an untraced agent." + fi + + # 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 + readable in the sandbox. Mount it read-only and give the path it has ON + THE HOST - that is where sbx mounts it inside too, e.g. + sbx run ... \"\$HOME/Library/Application Support/chainloop:ro\"" + fi + mkdir -p "$HOME/.config/chainloop" + install -m 600 "$CL_TRACE_CONFIG_SOURCE" "$HOME/.config/chainloop/config.toml" + echo "[chainloop-trace] Adopted chainloop config from $CL_TRACE_CONFIG_SOURCE" >&2 + fi + + # 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= + or mount your host config and point at it with + --kit-arg chainloopConfig=" + fi + + # 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") + # 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. + + Run this in the repository first, then start the sandbox again: + + chainloop trace init + + 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. + + 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 + exec claude --dangerously-skip-permissions "$@" + +environment: + variables: + # 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, so it cannot bind to the CLI's own flags. + CL_TRACE_CONFIG_SOURCE: ${{ kit.args.chainloopConfig }} + # 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. Do NOT redeclare it - + # 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). 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. + network: + allow: + - "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 - the attestation egresses here. + # Other forges: add your own host. + +agentInstructions: + # 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 + activity are attested and sent to Chainloop (as a CHAINLOOP_AI_CODING_SESSION + attestation) when you `git push` from this sandbox. No push, no record - + so push before the sandbox is reclaimed. diff --git a/devel/sandbox-kit/spec.yaml b/devel/sandbox-kit/spec.yaml deleted file mode 100644 index 25ef8caf3..000000000 --- a/devel/sandbox-kit/spec.yaml +++ /dev/null @@ -1,396 +0,0 @@ -# !!! 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 -schemaVersion: "2" -kind: sandbox # REQUIRED: only kind:sandbox may set an entrypoint -name: chainloop-trace-claude -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. - -# 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. -# -# 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. -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. - 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 - config-file login session and logs "Both user credentials and - $CHAINLOOP_TOKEN set. Ignoring user credentials." (app/cli/cmd/root.go). - chainloopConfig: - default: "" - description: >- - Absolute path INSIDE the sandbox to a chainloop config.toml to adopt - - 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 - 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: -# - 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 -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.) - entrypoint: ["/home/agent/.local/bin/cl-trace-wrap.sh"] - -setup: # v1: `commands:` - 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). - - 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:` - - path: /home/agent/.local/bin/cl-trace-wrap.sh - mode: "0755" - content: | - #!/usr/bin/env bash - set -euo pipefail - - run_plain() { - 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. - die() { - echo "[chainloop-trace] ERROR: $1" >&2 - exit 1 - } - - if ! command -v chainloop >/dev/null 2>&1; then - die "chainloop CLI not on PATH - the kit's install step did not complete - (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. - 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 - readable in the sandbox. Mount it read-only and give the path it has ON - THE HOST - that is where sbx mounts it inside too, e.g. - sbx run ... \"\$HOME/Library/Application Support/chainloop:ro\"" - fi - mkdir -p "$HOME/.config/chainloop" - install -m 600 "$CL_TRACE_CONFIG_SOURCE" "$HOME/.config/chainloop/config.toml" - 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. - 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= - or mount your host config and point at it with - --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 - - 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 - - if [ "$mode" = auto ]; then - if [ "$detected" = 1 ]; then mode=persistent; else mode=run; fi - fi - - 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 - - # 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 - - echo "[chainloop-trace] Recording this session to Chainloop (trace run)" >&2 - echo "[chainloop-trace] org=$CHAINLOOP_ORG project=$CHAINLOOP_PROJECT workflow=$CHAINLOOP_WORKFLOW" >&2 - - exec chainloop trace run \ - --org "$CHAINLOOP_ORG" \ - --project "$CHAINLOOP_PROJECT" \ - --workflow "$CHAINLOOP_WORKFLOW" \ - --claude \ - -- claude --dangerously-skip-permissions "$@" - -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" - # - # 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. - 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. - 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. - 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. - # - # (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 - # 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. - # - # - 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 - # --------------------------------------------------------------------------- - -permissions: # v1: `network.allowedDomains:` - # 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. - 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 EE 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 - - "dl.chainloop.dev:443" # EE CLI installer - - "chainloop-baafegchfnekdcde.z02.azurefd.net:443" # EE 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:` - # 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. - content: | - ## 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. diff --git a/sbxenv.yaml b/sbxenv.yaml index e4954186f..562c76112 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 @@ -67,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 @@ -78,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: @@ -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,