Skip to content

Commit bc8bfd3

Browse files
feat(trace): add local mode init and standalone export command
Add `trace init --local`, which sets up AI coding session tracing without a control plane: it installs the hooks but creates no workflow and needs no login. In local mode the pre-push hook pushes no attestation, and the evidence is written to disk instead. Local mode is marked in the per-clone trace state, not in the shared .chainloop.yml, so it does not turn attestation off for other clones. Add `trace export <dir>`, which writes the evidence of the sessions the hooks have already recorded to a local directory, reusing the same assembly a push does. Refs #3582 Chainloop-Trace-Sessions: d139ac66-b95f-4920-ac38-12997996037d Signed-off-by: Matías Insaurralde <matias@chainloop.dev>
1 parent 52eda65 commit bc8bfd3

12 files changed

Lines changed: 563 additions & 18 deletions

File tree

‎app/cli/cmd/trace.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func newTraceCmd() *cobra.Command {
3333
cmd.AddCommand(
3434
newTraceInitCmd(),
3535
newTraceRunCmd(),
36+
newTraceExportCmd(),
3637
newTraceUninstallCmd(),
3738
newTraceHookCmd(),
3839
)

‎app/cli/cmd/trace_export.go‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
20+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
21+
"github.com/spf13/cobra"
22+
)
23+
24+
const traceExportLong = `Write the recorded AI coding sessions of this repository to a local directory,
25+
instead of pushing a signed attestation.
26+
27+
It runs the same assembly a push does: it reads each spec source, redacts
28+
secrets, replaces copies with pointers, and stores each spec, skill, and image
29+
as its own material. Every material is written under its content digest, next to
30+
a manifest that lists them, so the output equals what a push would upload apart
31+
from the signature.
32+
33+
It makes no call to the control plane and needs no credentials or network. Pair
34+
it with "chainloop trace init --local": that sets the repository up to record
35+
sessions without a backend, and this writes their evidence to disk. --no-redact
36+
turns off secret redaction for a trusted local run, so the output can then hold
37+
secrets.`
38+
39+
// newTraceExportCmd creates the `trace export` subcommand.
40+
func newTraceExportCmd() *cobra.Command {
41+
var noRedact bool
42+
43+
cmd := &cobra.Command{
44+
Use: "export <dir>",
45+
Short: "Write the recorded AI coding session evidence to a local directory",
46+
Long: traceExportLong,
47+
Args: cobra.ExactArgs(1),
48+
// The export talks to no control plane, so it needs no authenticated
49+
// action options.
50+
Annotations: map[string]string{
51+
skipActionOptsInit: trueString,
52+
},
53+
RunE: func(cmd *cobra.Command, args []string) error {
54+
dir, err := action.RunTraceExport(cmd.Context(), logger, action.RunTraceExportOpts{
55+
OutDir: args[0],
56+
NoRedact: noRedact,
57+
Mode: aicodingsession.ModeCoding,
58+
})
59+
if err != nil {
60+
return err
61+
}
62+
63+
if dir == "" {
64+
logger.Info().Msg("no AI coding session was recorded, so nothing was exported")
65+
}
66+
67+
return nil
68+
},
69+
}
70+
71+
cmd.Flags().BoolVar(&noRedact, "no-redact", false, "disable secret redaction; for a trusted local run only, as the output can then hold secrets")
72+
73+
return cmd
74+
}

‎app/cli/cmd/trace_export_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
func TestTraceExportCommand(t *testing.T) {
28+
t.Run("needs exactly one directory argument", func(t *testing.T) {
29+
cmd := newTraceExportCmd()
30+
cmd.SetArgs(nil)
31+
cmd.SilenceUsage = true
32+
cmd.SilenceErrors = true
33+
assert.Error(t, cmd.Execute())
34+
})
35+
36+
t.Run("does not require the control plane", func(t *testing.T) {
37+
// The export talks to no control plane, so the command opts out of the
38+
// authenticated action-options setup the root command does.
39+
cmd := newTraceExportCmd()
40+
assert.Equal(t, trueString, cmd.Annotations[skipActionOptsInit])
41+
})
42+
43+
t.Run("exports nothing when no session is recorded", func(t *testing.T) {
44+
repoRoot := t.TempDir()
45+
require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o755))
46+
t.Chdir(repoRoot)
47+
out := t.TempDir()
48+
49+
cmd := newTraceExportCmd()
50+
cmd.SetArgs([]string{out})
51+
require.NoError(t, cmd.Execute())
52+
})
53+
}

‎app/cli/cmd/trace_init.go‎

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func newTraceInitCmd() *cobra.Command {
4242
var (
4343
project string
4444
contract string
45+
localFlag bool
4546
claudeFlag bool
4647
cursorFlag bool
4748
opencodeFlag bool
@@ -71,7 +72,14 @@ required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to
7172
turn the questions off on a terminal too.
7273
7374
The organization, project, workflow and require-trace values are saved to
74-
.chainloop.yml, and every push reads them from there.`,
75+
.chainloop.yml, and every push reads them from there.
76+
77+
With --local, the repository is set up without a control plane: it installs the
78+
same hooks but creates no workflow, needs no login, and asks for no organization
79+
or project. The pre-push hook then pushes no attestation. Sessions are recorded
80+
as usual, and their evidence is written to disk with "chainloop trace export".
81+
Local mode is per-clone: it is marked in the local trace state, not in the shared
82+
.chainloop.yml, so it never turns off attestation for teammates.`,
7583
RunE: func(cmd *cobra.Command, _ []string) error {
7684
gitDir, repoRoot, err := tracegit.FindGitDirAndRoot()
7785
if err != nil {
@@ -83,14 +91,31 @@ The organization, project, workflow and require-trace values are saved to
8391
return err
8492
}
8593

94+
// Local mode creates no workflow and pushes no attestation, so the
95+
// flags that only shape those are contradictions rather than extra
96+
// configuration. Reject them instead of ignoring them silently.
97+
if localFlag {
98+
if contract != "" {
99+
return fmt.Errorf("--contract cannot be used with --local: local mode creates no workflow and contacts no control plane")
100+
}
101+
if cmd.Flags().Changed("require-trace") {
102+
return fmt.Errorf("--require-trace cannot be used with --local: local mode pushes no attestation, so there is nothing to require")
103+
}
104+
}
105+
86106
// Fill in whatever the flags and .chainloop.yml did not settle,
87107
// asking the user when there is one to ask. The executor it hands
88108
// back is pinned to the resolved organization and reused below.
89-
executor, err := resolveTraceIdentity(cmd.Context(), cfg, repoRoot)
90-
if err != nil {
91-
return stopIfAborted(err)
109+
// Local mode talks to no control plane, so it resolves no identity
110+
// and opens no executor.
111+
var executor *action.AttestationExecutor
112+
if !localFlag {
113+
executor, err = resolveTraceIdentity(cmd.Context(), cfg, repoRoot)
114+
if err != nil {
115+
return stopIfAborted(err)
116+
}
117+
defer func() { _ = executor.Close() }()
92118
}
93-
defer func() { _ = executor.Close() }()
94119

95120
// Ask which agents to trace before anything is created or written,
96121
// so every question is answered up front and an abort leaves the
@@ -107,8 +132,12 @@ The organization, project, workflow and require-trace values are saved to
107132
return fmt.Errorf("no trace providers selected")
108133
}
109134

110-
if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil {
111-
return err
135+
// Local mode has no control plane to create the workflow in; the
136+
// evidence is exported to disk rather than attested to a workflow.
137+
if !localFlag {
138+
if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil {
139+
return err
140+
}
112141
}
113142

114143
// The harness hooks come first of everything that is left behind.
@@ -171,7 +200,7 @@ The organization, project, workflow and require-trace values are saved to
171200
// local, but a failure part-way through it would leave a repository
172201
// half set up: harness hooks calling a configuration that is not
173202
// there, or a configuration with nothing recording into it.
174-
if err := writeTraceInitState(cfg, repoRoot, gitDir); err != nil {
203+
if err := writeTraceInitState(cfg, repoRoot, gitDir, localFlag); err != nil {
175204
for _, snapshot := range before {
176205
if rerr := snapshot.restore(); rerr != nil {
177206
logger.Debug().Err(rerr).Str("path", snapshot.path).
@@ -189,13 +218,20 @@ The organization, project, workflow and require-trace values are saved to
189218
workDir = repoRoot
190219
}
191220

221+
// What the run produced, and what to do with it. Every step above logs
222+
// at debug, so this is what the user is left with.
223+
if localFlag {
224+
writeTraceInitLocalSummary(os.Stdout, providerNames(installed))
225+
writeTraceLocalNextSteps(os.Stdout, repoRoot, workDir, installed)
226+
227+
return nil
228+
}
229+
192230
// Nothing pinned an organization when cfg has none, so the workflow
193231
// went to the one the CLI points at. That is the one to report: it is
194232
// what the connection used, not a guess.
195233
organization := cmp.Or(cfg.organization, viper.GetString(confOptions.organization.viperKey))
196234

197-
// What the run produced, and what to do with it. Every step above logs
198-
// at debug, so this is what the user is left with.
199235
writeTraceInitSummary(os.Stdout, organization, cfg.project, cfg.workflow, providerNames(installed))
200236
writeTraceNextSteps(os.Stdout, repoRoot, workDir, installed)
201237

@@ -205,6 +241,7 @@ The organization, project, workflow and require-trace values are saved to
205241

206242
cmd.Flags().StringVar(&project, "project", "", "chainloop project name")
207243
cmd.Flags().StringVar(&contract, "contract", "", traceContractFlagDesc)
244+
cmd.Flags().BoolVar(&localFlag, "local", false, "set up backend-free local mode: no control plane, no login, no workflow; export the evidence to disk with 'chainloop trace export'")
208245
cmd.Flags().String("workflow", "", "chainloop workflow name used for trace attestations (defaults to \"ai-coding-session\")")
209246
cmd.Flags().Bool("require-trace", false, "block pushes when attestation fails for AI-assisted commits")
210247
cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)")
@@ -235,6 +272,44 @@ func writeTraceInitSummary(w io.Writer, organization, project, workflow string,
235272
fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", "))
236273
}
237274

275+
// writeTraceInitLocalSummary reports what a local init set up. It names no
276+
// organization, project or workflow, because local mode has none: it records
277+
// sessions and writes their evidence to disk, with no control plane.
278+
func writeTraceInitLocalSummary(w io.Writer, harnesses []string) {
279+
fmt.Fprint(w, "\nCongratulations, your repository is initialized in local mode\n\n")
280+
fmt.Fprintf(w, " mode local (no control plane)\n")
281+
fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", "))
282+
}
283+
284+
// writeTraceLocalNextSteps says what is left to do after a local init. Unlike
285+
// the backend path, there is nothing to push: the evidence is read off disk with
286+
// `chainloop trace export`, so that is the step that replaces "commit and push".
287+
func writeTraceLocalNextSteps(w io.Writer, repoRoot, workDir string, installed []trace.Provider) {
288+
files := make([]string, 0, len(installed))
289+
for _, p := range installed {
290+
absolute := p.SettingsFile(repoRoot)
291+
if rel, err := filepath.Rel(workDir, absolute); err == nil {
292+
files = append(files, rel)
293+
continue
294+
}
295+
296+
files = append(files, absolute)
297+
}
298+
299+
fmt.Fprintf(w, `
300+
What's next
301+
302+
1. Commit the harness configuration. Teammates then only need to run
303+
chainloop trace init (local mode is yours alone and is not committed):
304+
git add %s
305+
2. Start %s and write some code
306+
3. Write the session evidence to a directory to inspect it, no push needed:
307+
chainloop trace export ./trace-evidence
308+
309+
Learn more: %s
310+
`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL)
311+
}
312+
238313
// writeTraceNextSteps says what is left for the user to do. Committing comes
239314
// first: what init wrote into the repository is shared, and until it is
240315
// committed this setup exists in one working copy only.
@@ -398,7 +473,7 @@ func (f fileSnapshot) restore() error {
398473
// trace directory, the managed git hooks and the marker that says init ran. It
399474
// is one step so its caller can undo the harness hooks if any part of it fails,
400475
// rather than leaving a half-configured repository behind.
401-
func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string) error {
476+
func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string, local bool) error {
402477
if err := cfg.save(repoRoot); err != nil {
403478
return err
404479
}
@@ -419,6 +494,19 @@ func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string) error {
419494
return fmt.Errorf("mark trace initialized: %w", err)
420495
}
421496

497+
// The local marker lives in the per-clone trace state, not in the shared
498+
// .chainloop.yml, so it never turns attestation off for a teammate who
499+
// clones the repository and runs a normal init. A normal init clears any
500+
// stale marker, so re-running without --local switches the clone back to
501+
// pushing attestations.
502+
if local {
503+
if err := store.MarkLocal(); err != nil {
504+
return fmt.Errorf("mark local mode: %w", err)
505+
}
506+
} else if err := store.RemoveLocal(); err != nil {
507+
return fmt.Errorf("clear local mode: %w", err)
508+
}
509+
422510
return nil
423511
}
424512

0 commit comments

Comments
 (0)