Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/cli/cmd/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func newTraceCmd() *cobra.Command {
cmd.AddCommand(
newTraceInitCmd(),
newTraceRunCmd(),
newTraceExportCmd(),
newTraceUninstallCmd(),
newTraceHookCmd(),
)
Expand Down
74 changes: 74 additions & 0 deletions app/cli/cmd/trace_export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
"github.com/spf13/cobra"
)

const traceExportLong = `Write the recorded AI coding sessions of this repository to a local directory,
instead of pushing a signed attestation.

It runs the same assembly a push does: it reads each spec source, redacts
secrets, replaces copies with pointers, and stores each spec, skill, and image
as its own material. Every material is written under its content digest, next to
a manifest that lists them, so the output equals what a push would upload apart
from the signature.

It makes no call to the control plane and needs no credentials or network. Pair
it with "chainloop trace init --local": that sets the repository up to record
sessions without a backend, and this writes their evidence to disk. --no-redact
turns off secret redaction for a trusted local run, so the output can then hold
secrets.`

// newTraceExportCmd creates the `trace export` subcommand.
func newTraceExportCmd() *cobra.Command {
var noRedact bool

cmd := &cobra.Command{
Use: "export <dir>",
Short: "Write the recorded AI coding session evidence to a local directory",
Long: traceExportLong,
Args: cobra.ExactArgs(1),
// The export talks to no control plane, so it needs no authenticated
// action options.
Annotations: map[string]string{
skipActionOptsInit: trueString,
},
RunE: func(cmd *cobra.Command, args []string) error {
dir, err := action.RunTraceExport(cmd.Context(), logger, action.RunTraceExportOpts{
OutDir: args[0],
NoRedact: noRedact,
Mode: aicodingsession.ModeCoding,
})
if err != nil {
return err
}

if dir == "" {
logger.Info().Msg("no AI coding session was recorded, so nothing was exported")
}

return nil
},
}

cmd.Flags().BoolVar(&noRedact, "no-redact", false, "disable secret redaction; for a trusted local run only, as the output can then hold secrets")

return cmd
}
53 changes: 53 additions & 0 deletions app/cli/cmd/trace_export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestTraceExportCommand(t *testing.T) {
t.Run("needs exactly one directory argument", func(t *testing.T) {
cmd := newTraceExportCmd()
cmd.SetArgs(nil)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
assert.Error(t, cmd.Execute())
})

t.Run("does not require the control plane", func(t *testing.T) {
// The export talks to no control plane, so the command opts out of the
// authenticated action-options setup the root command does.
cmd := newTraceExportCmd()
assert.Equal(t, trueString, cmd.Annotations[skipActionOptsInit])
})

t.Run("exports nothing when no session is recorded", func(t *testing.T) {
repoRoot := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o755))
t.Chdir(repoRoot)
out := t.TempDir()

cmd := newTraceExportCmd()
cmd.SetArgs([]string{out})
require.NoError(t, cmd.Execute())
})
}
110 changes: 99 additions & 11 deletions app/cli/cmd/trace_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func newTraceInitCmd() *cobra.Command {
var (
project string
contract string
localFlag bool
claudeFlag bool
cursorFlag bool
opencodeFlag bool
Expand Down Expand Up @@ -71,7 +72,14 @@ required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to
turn the questions off on a terminal too.

The organization, project, workflow and require-trace values are saved to
.chainloop.yml, and every push reads them from there.`,
.chainloop.yml, and every push reads them from there.

With --local, the repository is set up without a control plane: it installs the
same hooks but creates no workflow, needs no login, and asks for no organization
or project. The pre-push hook then pushes no attestation. Sessions are recorded
as usual, and their evidence is written to disk with "chainloop trace export".
Local mode is per-clone: it is marked in the local trace state, not in the shared
.chainloop.yml, so it never turns off attestation for teammates.`,
RunE: func(cmd *cobra.Command, _ []string) error {
gitDir, repoRoot, err := tracegit.FindGitDirAndRoot()
if err != nil {
Expand All @@ -83,14 +91,31 @@ The organization, project, workflow and require-trace values are saved to
return err
}

// Local mode creates no workflow and pushes no attestation, so the
// flags that only shape those are contradictions rather than extra
// configuration. Reject them instead of ignoring them silently.
if localFlag {
if contract != "" {
return fmt.Errorf("--contract cannot be used with --local: local mode creates no workflow and contacts no control plane")
}
if cmd.Flags().Changed("require-trace") {
return fmt.Errorf("--require-trace cannot be used with --local: local mode pushes no attestation, so there is nothing to require")
}
}

// Fill in whatever the flags and .chainloop.yml did not settle,
// asking the user when there is one to ask. The executor it hands
// back is pinned to the resolved organization and reused below.
executor, err := resolveTraceIdentity(cmd.Context(), cfg, repoRoot)
if err != nil {
return stopIfAborted(err)
// Local mode talks to no control plane, so it resolves no identity
// and opens no executor.
var executor *action.AttestationExecutor
if !localFlag {
executor, err = resolveTraceIdentity(cmd.Context(), cfg, repoRoot)
if err != nil {
return stopIfAborted(err)
}
defer func() { _ = executor.Close() }()
}
defer func() { _ = executor.Close() }()

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

if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil {
return err
// Local mode has no control plane to create the workflow in; the
// evidence is exported to disk rather than attested to a workflow.
if !localFlag {
if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil {
return err
}
}

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

// What the run produced, and what to do with it. Every step above logs
// at debug, so this is what the user is left with.
if localFlag {
writeTraceInitLocalSummary(os.Stdout, providerNames(installed))
writeTraceLocalNextSteps(os.Stdout, repoRoot, workDir, installed)

return nil
}

// Nothing pinned an organization when cfg has none, so the workflow
// went to the one the CLI points at. That is the one to report: it is
// what the connection used, not a guess.
organization := cmp.Or(cfg.organization, viper.GetString(confOptions.organization.viperKey))

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

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

cmd.Flags().StringVar(&project, "project", "", "chainloop project name")
cmd.Flags().StringVar(&contract, "contract", "", traceContractFlagDesc)
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'")
cmd.Flags().String("workflow", "", "chainloop workflow name used for trace attestations (defaults to \"ai-coding-session\")")
cmd.Flags().Bool("require-trace", false, "block pushes when attestation fails for AI-assisted commits")
cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)")
Expand Down Expand Up @@ -235,6 +272,44 @@ func writeTraceInitSummary(w io.Writer, organization, project, workflow string,
fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", "))
}

// writeTraceInitLocalSummary reports what a local init set up. It names no
// organization, project or workflow, because local mode has none: it records
// sessions and writes their evidence to disk, with no control plane.
func writeTraceInitLocalSummary(w io.Writer, harnesses []string) {
fmt.Fprint(w, "\nCongratulations, your repository is initialized in local mode\n\n")
fmt.Fprintf(w, " mode local (no control plane)\n")
fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", "))
}

// writeTraceLocalNextSteps says what is left to do after a local init. Unlike
// the backend path, there is nothing to push: the evidence is read off disk with
// `chainloop trace export`, so that is the step that replaces "commit and push".
func writeTraceLocalNextSteps(w io.Writer, repoRoot, workDir string, installed []trace.Provider) {
files := make([]string, 0, len(installed))
for _, p := range installed {
absolute := p.SettingsFile(repoRoot)
if rel, err := filepath.Rel(workDir, absolute); err == nil {
files = append(files, rel)
continue
}

files = append(files, absolute)
}

fmt.Fprintf(w, `
What's next

1. Commit the harness configuration. Teammates then only need to run
chainloop trace init (local mode is yours alone and is not committed):
git add %s
2. Start %s and write some code
3. Write the session evidence to a directory to inspect it, no push needed:
chainloop trace export ./trace-evidence

Learn more: %s
`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL)
}

// writeTraceNextSteps says what is left for the user to do. Committing comes
// first: what init wrote into the repository is shared, and until it is
// committed this setup exists in one working copy only.
Expand Down Expand Up @@ -398,7 +473,7 @@ func (f fileSnapshot) restore() error {
// trace directory, the managed git hooks and the marker that says init ran. It
// is one step so its caller can undo the harness hooks if any part of it fails,
// rather than leaving a half-configured repository behind.
func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string) error {
func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string, local bool) error {
if err := cfg.save(repoRoot); err != nil {
return err
}
Expand All @@ -419,6 +494,19 @@ func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string) error {
return fmt.Errorf("mark trace initialized: %w", err)
}

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

return nil
}

Expand Down
Loading
Loading