From 027ae69488200ac5b2029069fb20b57a535acd72 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 24 Aug 2026 16:43:04 +0200 Subject: [PATCH 1/2] feat(devel): add Docker Sandboxes environment for Chainloop-traced Claude Code Add a declarative sandbox environment that runs Claude Code inside a Docker Sandbox with `chainloop trace` already wired in, so sessions working on this repo are recorded as AI coding sessions and attested to Chainloop without per-developer setup. `sbxenv.yaml` declares the environment: it selects the kit, passes the Chainloop API token as a kit argument and pins persistent trace mode, so the attestation is pushed by the managed pre-push hook using the identity in `.chainloop.yml`. `devel/sandbox-kit/` vendors the `chainloop-trace-claude` kit, a fork of the built-in `claude` agent that wires in the trace hooks and keeps Chainloop hosts off the egress proxy's intercepted path. Also ignore `.claude/settings.local.json` everywhere, since those files hold per-developer permission allowlists. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: f61b6087-ca58-4786-9a9f-86ad15a55e03 --- .gitignore | 1 + devel/sandbox-kit/README.md | 190 ++++++++++++++++++ devel/sandbox-kit/spec.yaml | 386 ++++++++++++++++++++++++++++++++++++ sbxenv.yaml | 119 +++++++++++ 4 files changed, 696 insertions(+) create mode 100644 devel/sandbox-kit/README.md create mode 100644 devel/sandbox-kit/spec.yaml create mode 100644 sbxenv.yaml diff --git a/.gitignore b/.gitignore index f6ec620f9..e64123869 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ config.local.yaml devel/dex/config.dev.local.yaml devel/compose.override.yml .claude/worktrees/ +**/.claude/settings.local.json diff --git a/devel/sandbox-kit/README.md b/devel/sandbox-kit/README.md new file mode 100644 index 000000000..13ab2dd62 --- /dev/null +++ b/devel/sandbox-kit/README.md @@ -0,0 +1,190 @@ +# `chainloop-trace-claude` — Docker Sandboxes kit + +Runs Claude Code inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) with `chainloop trace` +already wired in, so a session working on this repo is recorded as an +[AI coding session](https://docs.chainloop.dev/concepts/ai-coding-sessions) — model, token usage and cost, +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 +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. + +## Prerequisites + +- **An `sbx@nightly` build — not the v0.39.0 stable release.** This is a hard requirement; see +[below](#why-nightly) for why, and for the one command that tells you whether your build is affected. + ```bash + brew uninstall --cask sbx # conflicts with the nightly cask + brew install --cask docker/tap/sbx@nightly + sbx daemon restart # so the daemon matches the CLI + ``` +- **Docker running**, for the sandbox VMs. +- **Chainloop credentials** — either an org-scoped API token +(`chainloop organization api-token create`) or an existing `chainloop auth login` session. One or the other; +see [Two ways to run it](#two-ways-to-run-it). +- **Anthropic credentials** for Claude Code itself — a host-side secret (`sbx secret set -g anthropic`) or an +interactive login inside the sandbox. Independent of everything Chainloop. + +## Two ways to run it + +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`. + +### 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 +only thing left to pass is the token: + +```bash +sbx env run --env-arg chainloopToken="$CHAINLOOP_TOKEN" +``` + +### 2. Driving the kit directly + +No environment file. Either let an **exported token** be picked up automatically — a bare `-e KEY` takes the +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 +``` + +…or skip the token entirely and **authenticate from your existing `chainloop auth login` session**, by +mounting the config the CLI already wrote on your machine: + +```bash +CFG="$HOME/Library/Application Support/chainloop" # macOS +# CFG="$HOME/.config/chainloop" # Linux + +sbx run --clone --kit ./devel/sandbox-kit \ + --kit-arg chainloopConfig="$CFG/config.toml" \ + chainloop-trace-claude \ + . "${CFG}:ro" +``` + +The trailing line is positional: `.` is the workspace (must be read-write), `"${CFG}:ro"` the extra read-only +mount. `sbx` mounts it at the same absolute path it has on the host, and the kit copies it to +`/home/agent/.config/chainloop/config.toml` — where the in-VM CLI looks, per `chainloop config view`. On +attach: + +``` +[chainloop-trace] Adopted chainloop config from /Users/…/chainloop/config.toml +[chainloop-trace] Repo already initialized for chainloop trace - persistent mode +``` + +### Which to use + +| | 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 | + +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 +the attestation you expected isn't there. + +--- + +## Details and sharp edges + +**Why the token is passed in, and not stored in `sbx`'s secret manager.** The obvious approach — +`sbx secret set` / `sbx secret set-custom`, where the sandbox only ever holds a placeholder and the egress +proxy swaps in the real secret per request — cannot work for Chainloop today. To rewrite a header the proxy +has to terminate TLS, and its intercepted path does not negotiate the `h2` ALPN; `chainloop trace` talks to +the control plane over gRPC, and grpc-go ≥1.67 aborts with `missing selected ALPN property` rather than fall +back to HTTP/1.1. So declaring the Chainloop hosts for injection would *break* tracing rather than secure it, +and the kit keeps those hosts on `NO_PROXY` and takes the real token as a value. Until Docker Sandboxes +supports HTTP/2 gRPC through its header-rewriting proxy, the token lives in the VM's environment. + +That also means a `secrets:` entry in an `sbxenv.yaml` is the wrong tool here, even though it looks +purpose-built (`ref: op://…`, `command: gh auth token`): it is keyed by *service* and delivers through that +same injection path, so it would flip the Chainloop hosts to the intercepted path and kill attestation. + +**Keep the token out of your shell history and `ps`** — put it in a file instead of on the command line: + +```bash +printf 'chainloopToken=%s\n' "$CHAINLOOP_TOKEN" > ~/.config/chainloop/kit-args +chmod 600 ~/.config/chainloop/kit-args + +sbx run --clone --kit ./devel/sandbox-kit \ + --kit-args-file ~/.config/chainloop/kit-args \ + chainloop-trace-claude +``` + +`sbx env run` has the equivalent `--env-args-file`. + +**Brace the variable: `"${CFG}:ro"`, never `"$CFG:ro"`.** In zsh, `$VAR:r` is the remove-extension history +modifier and it fires *even inside double quotes*, so `"$CFG:ro"` becomes `…/chainloop` **`o`**. `sbx` then +offers to create that non-existent directory; accept and you get an empty read-only mount and +`chainloopConfig points at … not readable`. + +```bash +$ CFG=/tmp/demo/chainloop; echo "$CFG:ro"; echo "${CFG}:ro" +/tmp/demo/chainloopo # zsh ate the :r +/tmp/demo/chainloop:ro # what you meant +``` + +**What you are mounting.** `config.toml`'s `[auth] token` is your interactive login session, not an API +token: audience `user-auth.chainloop`, carries your `user_id`, and expires in **days**. The sandbox +authenticates as *you* and silently stops attesting when the session lapses — in persistent mode you find out +at `git push`. Good for a local demo, or for picking up self-hosted control-plane/CAS/platform endpoints +without editing the kit. Wrong for anything scheduled or shared, which is why `sbxenv.yaml` does not do it. + +**Passing both is safe and sometimes useful** — the config supplies the endpoints, the token authenticates. +The CLI prefers an exported `CHAINLOOP_TOKEN` and says so: `Both user credentials and $CHAINLOOP_TOKEN set. +Ignoring user credentials.` + +**The `config.toml` route with `sbx env run`** needs an overlay file, because `sbx env run` takes environment +files as its positional arguments and has no mount flag — a mount can only come from `additionalWorkspaces:`: + +```bash +cat > ~/.config/chainloop/sbx-mount.sbxenv.yaml <<'EOF' +schemaVersion: "1" +additionalWorkspaces: + - path: /Users/YOU/Library/Application Support/chainloop + readOnly: true +EOF + +sbx env rm # mounts are fixed at creation; an existing sandbox will not adopt one +sbx env run . ~/.config/chainloop/sbx-mount.sbxenv.yaml \ + --kit-arg chainloopConfig="$CFG/config.toml" +``` + +Pass that same pair of paths to every lifecycle command (`sbx env create`/`exec`/`rm`) — the path set is what +identifies the environment. Do **not** put this in `~/.sbxenv.yaml`: that file is read on every `sbx env` +call, and in a directory with no project file it is validated alone and fails with `agent is required`, +breaking unrelated commands. + +**Other things that will surprise you:** + +- `sbx exec -- chainloop …` on a sandbox you have never attached to sees no config. The copy runs +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. + +## Why nightly + +**Not** the v0.39.0 stable release. The kit inherits the built-in `claude` agent with `extends:`, and +[docker/sbx-releases#415](https://github.com/docker/sbx-releases/issues/415) — a child `setup:` block +silently dropping the parent's — was fixed in nightly and then **regressed in the v0.39.0 release**. + +``` +<=v0.38.1 BROKEN +nightly-202608180320 (rc1-245) ok +v0.39.0 stable (def8cb0) BROKEN AGAIN +nightly-202608240324 (rc1-441) ok <- verified +``` + +On a broken build the sandbox still starts, but `~/.claude/*` stays owned by `root:root`, so the agent cannot +write the transcript `chainloop trace` reads — **the session records nothing, with no error**. Check before +blaming anything else: + +```bash +sbx exec -- stat -c '%U:%G' /home/agent/.claude/projects # want agent:agent +``` diff --git a/devel/sandbox-kit/spec.yaml b/devel/sandbox-kit/spec.yaml new file mode 100644 index 000000000..f9d148ff3 --- /dev/null +++ b/devel/sandbox-kit/spec.yaml @@ -0,0 +1,386 @@ +# !!! 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. + DO_NOT_TRACK: "1" # silence PostHog telemetry under restricted egress + # 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 + - "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 + - "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 new file mode 100644 index 000000000..9ed227326 --- /dev/null +++ b/sbxenv.yaml @@ -0,0 +1,119 @@ +# Docker Sandboxes declarative environment — EXPERIMENTAL (sbx v0.39+). +# +# Launches Claude Code in a Docker Sandbox with Chainloop trace already wired +# in, working on a private in-container clone of this repo. +# +# sbx env plan # show what it would do +# sbx env run --env-arg chainloopToken="$CHAINLOOP_TOKEN" +# sbx env rm # tear down + drop scoped creds +# +# This repo is ALREADY initialized for `chainloop trace` (.chainloop.yml with +# projectName + the `chainloop trace hook` entries in .claude/settings.json), so +# the kit runs in PERSISTENT mode: the session is recorded locally and the +# attestation is pushed by the managed pre-push hook on `git push`. Identity +# (org=chainloop, project=chainloop) comes from .chainloop.yml, NOT from here. +# No push => no attestation. Push from inside the sandbox before it is reclaimed. +# +# ── FILENAME (read this before renaming the file) ─────────────────────────── +# The name is not stable across sbx builds, and the wrong one is IGNORED +# SILENTLY rather than reported: +# - docs.docker.com and v0.39.0 stable -> `.sbxenv.yaml` (falls back to .yml) +# - sbx@nightly (v0.39.0-rc1-441+) -> `sbxenv.yaml`, and the hidden name +# is explicitly retired: "a project still holding one now reads as having +# none". Verified: `sbx env plan` in a dir holding only `.sbxenv.yaml` says +# `ERROR: no sbxenv.yaml found at .../sbxenv.yaml`. +# Only the non-hidden name is carried here, deliberately: this kit REQUIRES +# nightly (see below), and nightly reads only `sbxenv.yaml` — so a `.sbxenv.yaml` +# would serve nothing but builds that cannot run the kit anyway. If Docker ever +# reverts the rename, add the dotted name back. Note `~/.sbxenv.yaml` (the +# optional per-user base layer merged underneath) keeps the dotted name even on +# nightly — but do not put anything there: it is read on every `sbx env` call, +# and in a directory with no project file it is validated alone and fails with +# `agent is required`, breaking unrelated commands like `sbx env rm`. +# +# ── BUILD REQUIREMENT ────────────────────────────────────────────────────── +# Needs an sbx@nightly build, NOT v0.39.0 stable: the kit uses `extends: claude`, +# and docker/sbx-releases#415 (a child `setup:` block dropping the parent's) +# regressed in the v0.39.0 release. On a broken build the sandbox still starts, +# but the ~/.claude/* volumes stay root:root and the transcript that +# `chainloop trace` reads is never written — the session attests NOTHING, with +# no error. Check with: +# sbx exec -- stat -c '%U:%G' /home/agent/.claude/projects # want agent:agent + +schemaVersion: "1" +name: chainloop-traced-claude + +args: + chainloopToken: + default: "" + description: >- + Chainloop org-scoped API token (chainloop organization api-token create). + Must have access to the org pinned in .chainloop.yml (chainloop). + Supply THIS or chainloopConfig — with neither, the kit's wrapper refuses + to start rather than run an untraced session. + kitPath: + default: ./devel/sandbox-kit + 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 + 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 + --env-arg kitPath=/abs/path from anywhere else. + +# The kit's own name, forked from the built-in `claude` agent via `extends:`. +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. + args: + # CAVEAT: the environment plan prints this in CLEARTEXT — verified, and + # moving it off `env:` did not help; only a literal `secrets:` value is + # reduced to a sha256: digest. So the token is on screen at every + # 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 + +workspace: + path: . + # An in-container clone (host repo mounted read-only), reachable from the host + # as the `sandbox-` git remote. Required for per-file AI-vs-human line + # attribution, which needs a readable .git and at least one commit. + clone: false + +# Deliberately no additionalWorkspaces: the kit can also authenticate from a +# mounted host config.toml instead of a token (--kit-arg chainloopConfig=...), +# but that needs a mount whose path is per-developer, and it authenticates the +# sandbox as YOU with a session that expires in days — a local-demo convenience, +# not how this repo should be run. It also cannot be made conditional here: an +# empty `path:` is rejected (`additionalWorkspaces[0]: path is required`) and +# sbxenv has no conditionals. Use plain `sbx run` for that route; see +# devel/sandbox-kit/README.md. + +# NOTE — deliberately NOT declared here: +# env: nothing left to set. The Chainloop token and the mode reach 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, +# because proxy-managed injection (Path A) can never work for a +# gRPC client — the intercepted path does not negotiate h2. +# secrets: the `anthropic` key is already a global sbx service secret +# (`sbx secret ls`), and the kit inherits its binding + egress rules +# from the built-in claude agent. Declaring it again would scope a +# second copy to this environment. +# bindings: never bind a Chainloop host to a credential. Any credential +# targeting api.cp.chainloop.dev flips it from the proxy's tunnel to +# its intercepted path, which serves HTTP/1.1 only — and grpc-go +# >=1.67 then aborts with "missing selected ALPN property", so +# nothing is attested. The kit's NO_PROXY keeps those hosts direct. From e919119bfa1ce6f47f7e06b93aea433c16590bb1 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 24 Aug 2026 16:51:22 +0200 Subject: [PATCH 2/2] feat(devel): sign sandbox commits with the host's forwarded SSH key Stack the `git-ssh-sign` mixin kit onto the sandbox environment so commits and tags made inside the sandbox carry the developer's signature rather than only their name, which the repository's signed-commit requirement expects. The kit configures signing through `git config --system` and resolves the key from the forwarded agent at signing time, so no private key material enters the sandbox. It requires a key loaded in the host's SSH agent; without one, signing fails and every commit fails with it. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino --- sbxenv.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/sbxenv.yaml b/sbxenv.yaml index 9ed227326..e4954186f 100644 --- a/sbxenv.yaml +++ b/sbxenv.yaml @@ -85,6 +85,16 @@ kits: # 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: + # mixin` kit, so it stacks on the sandbox kit above rather than competing with + # it; it writes only to /etc/gitconfig (`git config --system`) and resolves the + # key at signing time from $SSH_AUTH_SOCK, so nothing private enters the VM. + # Prerequisite: the key must be loaded on the HOST (`ssh-add ~/.ssh/id_ed25519`); + # with no key in the agent, signing fails and so does every commit. + # Upstream: https://github.com/docker/sbx-kits-contrib/tree/main/git-ssh-sign + - source: docker.io/sbx/git-ssh-sign-kit:latest + workspace: path: . # An in-container clone (host repo mounted read-only), reachable from the host