1717# sbx run --clone -e CHAINLOOP_TOKEN ./devel/sandbox-kit/claude
1818# See devel/sandbox-kit/README.md for the sharp edges.
1919#
20- # !!! BUILD REQUIREMENT - this kit needs an sbx build where `extends:` merges
21- # !!! ADDITIVELY. A child `setup:` block silently dropping the parent's
22- # !!! (docker/sbx-releases#415) takes Claude's trust-flag seeding, MCP gateway
23- # !!! registration and the ~/.claude/* volume ownership with it, so the sandbox
24- # !!! starts but cannot write the transcript `chainloop trace` reads: it attests
25- # !!! NOTHING, with no error. Known: <=v0.38.1 broken, nightly rc1-245 ok,
26- # !!! v0.39.0 stable broken (#415 regressed in the release), rc1-441 ok.
27- # !!! Check a build with:
28- # !!! sbx exec <name> -- stat -c '%U:%G' /home/agent/.claude/projects # want agent:agent
20+ # Needs an sbx build whose `extends:` merges additively; on an affected build the
21+ # sandbox starts but records nothing. See "Why nightly" in the README.
2922schemaVersion : " 2"
3023kind : sandbox # REQUIRED: only kind:sandbox may set an entrypoint
3124name : chainloop-trace-claude
@@ -39,28 +32,11 @@ description: >-
3932 for `chainloop trace`: identity comes from its committed .chainloop.yml and the
4033 attestation is pushed on `git push`. Fork of the built-in `claude` agent.
4134
42- # Everything the caller has to supply arrives as a kit ARGUMENT, so this file
43- # holds no secrets and is committed as-is. `sbx kit validate ./kit` names any
44- # argument it still needs.
45- #
46- # ONE MODE ON PURPOSE - persistent, i.e. `chainloop trace init` has already
47- # written organization/projectName (and optionally the workflow, else
48- # "ai-coding-session") into the repo's .chainloop.yml, and the managed pre-push
49- # hook reads them from there. The kit contributes NOTHING but the token, which is
50- # why there are no org/project/workflow arguments.
51- #
52- # The other mode the CLI offers, `chainloop trace run`, is deliberately NOT
53- # supported here:
54- # - it "ignores .chainloop.yml entirely. The attestation identity must come
55- # from the --org, --project, --workflow flags (mandatory)"
56- # (`chainloop trace run --help`), so it needs three arguments that are dead
57- # weight for every repo this kit is actually pointed at;
58- # - its teardown wipes .git/chainloop-trace/ and strips the committed agent
59- # hooks from .claude/settings.json - destructive on a trace-initialized repo,
60- # which is the only kind this kit accepts.
61- # The trade-off that buys: a session whose work is never pushed attests nothing.
62- # That is intended - unpushed work is discarded work, and the sandbox is
63- # disposable. Push from inside the sandbox before it is reclaimed.
35+ # No secrets live here - everything the caller supplies is a kit argument, so
36+ # this file is committed as-is. There is no mode or identity argument: the kit
37+ # does persistent tracing only and reads org/project/workflow from the repo's
38+ # committed .chainloop.yml. See the README for why `chainloop trace run` is not
39+ # offered.
6440args :
6541 chainloopToken :
6642 default : " "
@@ -86,31 +62,18 @@ args:
8662 worthwhile for self-hosted. NOTE its [auth] token is your personal
8763 login session: short-lived (~days) and your full identity. Prefer
8864 chainloopToken for anything unattended.
89- # Inherit the built-in `claude` agent instead of restating it. This kit then
90- # declares ONLY the Chainloop delta; everything below comes for free and stays in
91- # step with sbx upgrades instead of drifting in a hand-copied spec:
92- # - sandbox.image (no tag to verify or bump)
93- # - the `anthropic` credential: proxy-injected x-api-key AND the OAuth block
94- # - Anthropic/Claude egress (api.anthropic.com, claude.com, downloads.claude.ai,
95- # mcp-proxy.anthropic.com, platform.claude.com, bridge.claudeusercontent.com)
96- # - IS_SANDBOX=1
97- # - ~/.claude.json trust+onboarding flags, ~/.claude/settings.json seeding, and
98- # `claude mcp add mcp-gateway` (MCP is invisible to the session without it)
99- # - the persistent ~/.claude/{projects,sessions,todos,shell-snapshots,statsig}
100- # volumes - projects/ holds the transcript `chainloop trace` reads
101- # - agentInstructions.filename: CLAUDE.md
65+ # Inherit the built-in `claude` agent - its image, Anthropic credential and
66+ # egress, MCP gateway registration and ~/.claude volumes - and declare only the
67+ # Chainloop delta below.
10268extends : claude
10369
10470sandbox :
105- # REPLACES the parent's [claude, --dangerously-skip-permissions] -> the wrap
106- # point. The wrapper re-adds that flag when it execs claude.
71+ # Replaces the parent's entrypoint; the wrapper re-adds its flags.
10772 entrypoint : ["/home/agent/.local/bin/cl-trace-wrap.sh"]
10873
10974setup :
11075 install :
111- # Install the chainloop CLI. The installer requires the target dir to exist,
112- # so create it first. Run as root so we can write to /usr/local/bin (hooks
113- # invoke `chainloop` by bare name -> must be on PATH).
76+ # The git hooks invoke `chainloop` by bare name, so it has to be on PATH.
11477 - command : >-
11578 mkdir -p /usr/local/bin &&
11679 curl -sfL https://dl.chainloop.dev/cli/install.sh | bash -s -- --path /usr/local/bin &&
@@ -128,10 +91,8 @@ setup:
12891 exec claude --dangerously-skip-permissions "$@"
12992 }
13093
131- # Every path that would hand back an UNTRACED agent is fatal instead.
132- # This kit exists to make a session provable, and a sandbox that quietly
133- # records nothing is the one outcome worse than not starting: you find
134- # out only when the attestation you expected is not there.
94+ # Anything that would hand back an untraced agent is fatal: a sandbox
95+ # that quietly records nothing is worse than one that refuses to start.
13596 die() {
13697 echo "[chainloop-trace] ERROR: $1" >&2
13798 exit 1
@@ -142,15 +103,8 @@ setup:
142103 (check egress to dl.chainloop.dev). Refusing to start an untraced agent."
143104 fi
144105
145- # Adopt a mounted host config.toml when one was pointed at. sbx mounts an
146- # extra read-only workspace at the SAME absolute path it has on the host,
147- # which is NOT where the CLI looks: `chainloop config view` inside the
148- # sandbox reports /home/agent/.config/chainloop/config.toml (Linux XDG),
149- # verified. Copy rather than pass --config, because in persistent mode the
150- # managed git hooks invoke `chainloop` as their own processes and a flag
151- # from this wrapper would never reach them.
152- # Deliberately not named CHAINLOOP_CONFIG: the CLI's viper prefix is
153- # CHAINLOOP, so that name could bind to its own --config flag.
106+ # Adopt a mounted host config.toml. Copied into place rather than passed
107+ # with --config, so the git hooks find it in their own processes too.
154108 if [ -n "$CL_TRACE_CONFIG_SOURCE" ]; then
155109 if [ ! -r "$CL_TRACE_CONFIG_SOURCE" ]; then
156110 die "chainloopConfig points at '$CL_TRACE_CONFIG_SOURCE', which is not
@@ -163,23 +117,17 @@ setup:
163117 echo "[chainloop-trace] Adopted chainloop config from $CL_TRACE_CONFIG_SOURCE" >&2
164118 fi
165119
166- # Something has to authenticate the attestation. An explicit token wins
167- # over a config-file login session - the CLI prefers an exported
168- # CHAINLOOP_TOKEN and logs "Both user credentials and $CHAINLOOP_TOKEN
169- # set. Ignoring user credentials." (app/cli/cmd/root.go) - but with
170- # neither, nothing can be pushed, so refuse rather than record nothing.
120+ # Without credentials nothing can be pushed, so refuse rather than
121+ # record nothing. A token wins over a config-file login session.
171122 if [ -z "$CHAINLOOP_TOKEN" ] && [ ! -r "$HOME/.config/chainloop/config.toml" ]; then
172123 die "no Chainloop credentials. Pass an org-scoped API token with
173124 --kit-arg chainloopToken=<token>
174125 or mount your host config and point at it with
175126 --kit-arg chainloopConfig=<path-inside-sandbox>"
176127 fi
177128
178- # Persistent tracing is the only mode: the repo must already be
179- # initialized, and the identity comes from its committed .chainloop.yml.
180- # (Every kit arg has a default, so the vars above are always defined and
181- # bare $VAR is safe under `set -u`; the validator rejects shell
182- # default-expansion inside setup.files content.)
129+ # Persistent tracing only: the repo must already be initialized, and the
130+ # identity comes from its committed .chainloop.yml.
183131 repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")
184132 detected=0
185133 for f in "$repo_root/.chainloop.yml" "$repo_root/.chainloop.yaml"; do
@@ -211,129 +159,45 @@ setup:
211159
212160environment :
213161 variables :
214- # NOTE: HOME is deliberately NOT declared. The agent and the hook subprocess
215- # MUST share it (transcript discovery is os.UserHomeDir()-based; a mismatch
216- # silently loses usage/cost/tools), but sbx already sets HOME=/home/agent on
217- # PID 1, so every child inherits it - verified in a plain `claude` sandbox.
218- # Self-hosted overrides only (defaults are the SaaS endpoints):
219- # CHAINLOOP_CONTROL_PLANE_API: "api.cp.chainloop.dev:443"
220- # CHAINLOOP_ARTIFACT_CAS_API: "api.cas.chainloop.dev:443"
221- #
222- # Path B: the REAL org-scoped API token is set directly in the sandbox env,
223- # from the required `chainloopToken` arg - so no secret is stored in this
224- # file and it is committed as-is.
225- # Do NOT use `sbx secret set-custom` / proxy-managed header injection for this
226- # token (see the commented-out Path A block under `credentials:`): injection
227- # requires the proxy to TERMINATE TLS, and the intercepted path does not
228- # negotiate the `h2` ALPN - which is exactly what grpc-go >=1.67 refuses.
162+ # HOME is deliberately not declared: the agent and the hook subprocess must
163+ # share it, and sbx already sets it on PID 1.
229164 CHAINLOOP_TOKEN : ${{ kit.args.chainloopToken }}
230- # Path ( inside the sandbox) of a mounted host config.toml for the wrapper to
231- # adopt. Not CHAINLOOP_-prefixed on purpose - see the wrapper 's note .
165+ # Path inside the sandbox of a mounted host config.toml for the wrapper to
166+ # adopt. Not CHAINLOOP_-prefixed, so it cannot bind to the CLI 's own flags .
232167 CL_TRACE_CONFIG_SOURCE : ${{ kit.args.chainloopConfig }}
233- #
234- # CRITICAL (gRPC vs the egress proxy): `chainloop trace` talks to the control
235- # plane over gRPC (HTTP/2 + ALPN). sbx sets HTTP(S)_PROXY=gateway.docker.internal:3128
236- # and grpc-go honors it, dialing via HTTP CONNECT. The proxy handles a host in
237- # one of two ways, and only one of them works for gRPC (probe any host with
238- # `curl -sv --http2 --proxy "$HTTPS_PROXY" https://<host>/` and read the issuer):
239- # TUNNEL - no credential targets the host. TLS is end-to-end (real
240- # Let's Encrypt cert), ALPN reaches the server, `h2` negotiated.
241- # INTERCEPTED - some credential targets the host, so the proxy terminates TLS
242- # to rewrite headers (issuer "Docker Sandboxes Proxy CA"). It
243- # answers `ALPN: server did not agree on a protocol` and drops
244- # to HTTP/1.1; grpc-go >=1.67 aborts with "missing selected ALPN
245- # property" and NO attestation is sent.
246- # A host flips to INTERCEPTED the moment ANY credential targets it - a kit
247- # apiKey.inject[].domain, OR a host-side `sbx secret set-custom --host
248- # api.cp.chainloop.dev --env CHAINLOOP_TOKEN`, which is easy to add from outside
249- # this repo and silently breaks tracing (verified on sbx v0.38.0: same kit,
250- # same empty token, tunnel before the custom secret existed and intercepted
251- # after; re-verified unchanged on v0.39.0-rc1-441). Bypassing the proxy for the
252- # Chainloop hosts makes tracing immune to that decision; egress is still
253- # enforced by sbx's allowlist. Keep localhost / gateway.docker.internal, which
254- # is what sbx's own default NO_PROXY carries.
168+ # The Chainloop hosts MUST bypass the egress proxy: `chainloop trace` speaks
169+ # gRPC, and the proxy's intercepted path cannot negotiate h2, so anything that
170+ # routes these hosts through it silently breaks attestation. See the README.
255171 NO_PROXY : " localhost,127.0.0.1,::1,gateway.docker.internal,api.cp.chainloop.dev,api.cas.chainloop.dev,api.app.chainloop.dev"
256172 no_proxy : " localhost,127.0.0.1,::1,gateway.docker.internal,api.cp.chainloop.dev,api.cas.chainloop.dev,api.app.chainloop.dev"
257173
258174credentials :
259- # No `anthropic` entry: it is inherited from the parent, including the OAuth
260- # block that a hand-written fork would have had to copy. Do NOT redeclare it -
261- # named arrays merge by identity key (credentials[].service) and a duplicate
262- # service across parent and child is an ERROR, not an override.
263-
264- # --- Path A (NOT USABLE TODAY - verified v0.38.0, re-verified on the v0.39 line)
265- # Proxy-managed injection of the Chainloop token, so the real secret never
266- # enters the VM. Uncomment this entry, drop the CHAINLOOP_TOKEN variable above
267- # and the chainloopToken arg, and store the secret on the host with
268- # sbx secret set-custom --host api.cp.chainloop.dev --env CHAINLOOP_TOKEN
269- #
270- # Why it cannot be enabled yet - TESTED END-TO-END, not inferred: injection only
271- # works on hosts the proxy INTERCEPTS (it must terminate TLS to rewrite the
272- # header), and the intercepted path answers `ALPN: server did not agree on a
273- # protocol`, dropping to HTTP/1.1. grpc-go >=1.67 then aborts with "missing
274- # selected ALPN property" and NO attestation is sent.
275- #
276- # !! This also applies to `sbx secret set-custom --host api.cp.chainloop.dev
277- # !! --env CHAINLOOP_TOKEN`, which is the same mechanism by another route (it
278- # !! substitutes a `sbx-cs-...` placeholder in outbound headers). A global custom
279- # !! secret for the Chainloop hosts is INERT only because this kit sets
280- # !! CHAINLOOP_TOKEN itself, which suppresses the binding. Stop setting the token
281- # !! here and the placeholder goes live, the hosts flip to the intercepted path,
282- # !! and tracing dies with the ALPN error. Verified on sbx v0.38.0:
283- # !! CHAINLOOP_TOKEN=sbx-cs-... -> issuer "Docker Sandboxes Proxy CA", HTTP/1.x
284- # !! kit-set CHAINLOOP_TOKEN -> issuer "Let's Encrypt", h2, gRPC works
285- #
286- # Re-test with:
287- # curl -sv --http2 --proxy "$HTTPS_PROXY" https://api.cp.chainloop.dev/
288- # and enable this once the issuer is "Docker Sandboxes Proxy CA" AND the server
289- # still agrees on h2.
175+ # No `anthropic` entry: it is inherited from the parent. Do NOT redeclare it -
176+ # a duplicate credentials[].service across parent and child is an ERROR.
290177 #
291- # - service: chainloop
292- # apiKey:
293- # name: CHAINLOOP_TOKEN
294- # proxyManaged: true
295- # inject:
296- # - domain: api.cp.chainloop.dev
297- # scheme: bearer # v2 shorthand for Authorization: Bearer %s
298- # - domain: api.cas.chainloop.dev
299- # scheme: bearer
300- # - domain: api.app.chainloop.dev
301- # scheme: bearer
302- # ---------------------------------------------------------------------------
178+ # Proxy-managed injection of the Chainloop token is not usable here: it needs
179+ # the proxy to terminate TLS, which breaks the kit's gRPC traffic. See the
180+ # README for the full reasoning and how to re-test it.
303181
304182permissions :
305- # Sandbox egress is deny-by-default. The Anthropic/Claude hosts come from the
306- # parent (set union, deduplicated, parent order first); these are the Chainloop
307- # additions.
183+ # Egress is deny-by-default; the Anthropic/Claude hosts come from the parent.
308184 network :
309185 allow :
310- - " api.cp.chainloop.dev:443" # control plane - ALWAYS (attest + keyless signing CSR)
311- - " api.cas.chainloop.dev:443" # CAS - only if the org CAS backend is external
312- - " api.app.chainloop.dev:443" # platform backend - the CLI also dials this
313- - " t.chainloop.dev:443" # CLI usage telemetry (PostHog) - current builds
314- - " crb.chainloop.dev:443" # CLI usage telemetry (PostHog) - binaries released
315- # before the endpoint moved. The host is compiled
316- # in, so an older tag checked out in a sandbox
317- # still reaches for it. Commands succeed either
318- # way, but a connection that is dropped rather
319- # than refused costs each tracked command a
320- # couple of seconds waiting on the send deadline.
321- - " timestamp.digicert.com:80" # RFC 3161 timestamp authority (plain HTTP, signed response)
322- # used during attestation signing; host comes from the
323- # control plane's signing options
324- - " buf.build:443" # Buf Schema Registry - `make api` / `buf generate`
325- # resolve the buf.yaml module deps and the remote
326- # plugins declared in the buf.gen.yaml files
186+ - " api.cp.chainloop.dev:443" # control plane
187+ - " api.cas.chainloop.dev:443" # CAS, when the org backend is external
188+ - " api.app.chainloop.dev:443" # platform backend
189+ - " t.chainloop.dev:443" # CLI telemetry
190+ - " crb.chainloop.dev:443" # CLI telemetry, older binaries
191+ - " timestamp.digicert.com:80" # RFC 3161 timestamp authority, used when signing
192+ - " buf.build:443" # Buf Schema Registry, for `make api`
327193 - " dl.chainloop.dev:443" # CLI installer
328194 - " chainloop-baafegchfnekdcde.z02.azurefd.net:443" # CLI download CDN
329- - " github.com:443" # git push over HTTPS in persistent mode - the
330- # pre-push attestation egresses on push. Other
331- # forges/self-hosted: add your own host here.
195+ - " github.com:443" # git push - the attestation egresses here.
196+ # Other forges: add your own host.
332197
333198agentInstructions :
334- # filename is inherited (CLAUDE.md). NOTE: `content` is a scalar, so this
335- # REPLACES the parent's CLAUDE.md text (its CLAUDE_ENV_FILE / shell-completion
336- # guidance) rather than appending to it.
199+ # filename is inherited (CLAUDE.md). `content` is a scalar, so this REPLACES
200+ # the parent's text rather than appending to it.
337201 content : |
338202 ## Chainloop tracing
339203 This session is recorded by Chainloop trace. Tool/MCP usage and coding
0 commit comments