diff --git a/app/cli/cmd/trace.go b/app/cli/cmd/trace.go
index 8d2434c54..783837f1d 100644
--- a/app/cli/cmd/trace.go
+++ b/app/cli/cmd/trace.go
@@ -33,6 +33,7 @@ func newTraceCmd() *cobra.Command {
cmd.AddCommand(
newTraceInitCmd(),
newTraceRunCmd(),
+ newTraceExportCmd(),
newTraceUninstallCmd(),
newTraceHookCmd(),
)
diff --git a/app/cli/cmd/trace_export.go b/app/cli/cmd/trace_export.go
new file mode 100644
index 000000000..feceee0de
--- /dev/null
+++ b/app/cli/cmd/trace_export.go
@@ -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
",
+ 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
+}
diff --git a/app/cli/cmd/trace_export_test.go b/app/cli/cmd/trace_export_test.go
new file mode 100644
index 000000000..c0b3b09a1
--- /dev/null
+++ b/app/cli/cmd/trace_export_test.go
@@ -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())
+ })
+}
diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go
index 8cecd15a6..04497c0a6 100644
--- a/app/cli/cmd/trace_init.go
+++ b/app/cli/cmd/trace_init.go
@@ -42,6 +42,7 @@ func newTraceInitCmd() *cobra.Command {
var (
project string
contract string
+ localFlag bool
claudeFlag bool
cursorFlag bool
opencodeFlag bool
@@ -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 {
@@ -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
@@ -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.
@@ -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).
@@ -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)
@@ -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)")
@@ -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.
@@ -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
}
@@ -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
}
diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go
new file mode 100644
index 000000000..0359450eb
--- /dev/null
+++ b/app/cli/cmd/trace_init_local_test.go
@@ -0,0 +1,103 @@
+//
+// 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/chainloop-dev/chainloop/app/cli/internal/trace/state"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestTraceInitLocal runs `trace init --local` end to end. It needs no control
+// plane, which is the point: the command resolves no identity and creates no
+// workflow, so the whole run stays on the machine.
+func TestTraceInitLocal(t *testing.T) {
+ repoRoot := t.TempDir()
+ gitDir := filepath.Join(repoRoot, ".git")
+ require.NoError(t, os.MkdirAll(gitDir, 0o755))
+ t.Chdir(repoRoot)
+
+ cmd := traceInitTestCmd(t, "--local", "--claude")
+ require.NoError(t, cmd.Execute())
+
+ store := state.NewGitStore(gitDir)
+ assert.True(t, store.IsLocal(), "the clone is marked as local")
+ assert.True(t, store.IsTraceInitialized(), "the clone is initialized")
+
+ // The harness hooks are installed, so sessions are recorded.
+ _, err := os.Stat(filepath.Join(repoRoot, claudeSettings))
+ assert.NoError(t, err, "the Claude hooks are installed")
+
+ // Local mode is per-clone, recorded in the trace state, never in the shared
+ // .chainloop.yml: a teammate who clones the repository and runs a normal
+ // init must not inherit a turned-off attestation.
+ _, err = os.Stat(filepath.Join(repoRoot, chainloopYMLName))
+ assert.True(t, os.IsNotExist(err), "local mode writes nothing to the shared config")
+}
+
+// TestWriteTraceInitStateTogglesLocal checks that re-initializing without
+// --local clears the per-clone local marker, so a clone switched back to the
+// backend path starts pushing attestations again.
+func TestWriteTraceInitStateTogglesLocal(t *testing.T) {
+ repoRoot := t.TempDir()
+ gitDir := filepath.Join(repoRoot, ".git")
+ require.NoError(t, os.MkdirAll(gitDir, 0o755))
+
+ cfg := &traceInitConfig{}
+ store := state.NewGitStore(gitDir)
+
+ require.NoError(t, writeTraceInitState(cfg, repoRoot, gitDir, true))
+ assert.True(t, store.IsLocal(), "a local init marks the clone local")
+
+ require.NoError(t, writeTraceInitState(cfg, repoRoot, gitDir, false))
+ assert.False(t, store.IsLocal(), "a normal init clears the stale local marker")
+}
+
+func TestTraceInitLocalRejectsContradictoryFlags(t *testing.T) {
+ testCases := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {
+ name: "contract",
+ args: []string{"--local", "--contract", "my-contract"},
+ want: "--contract cannot be used with --local",
+ },
+ {
+ name: "require-trace",
+ args: []string{"--local", "--require-trace"},
+ want: "--require-trace cannot be used with --local",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ repoRoot := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o755))
+ t.Chdir(repoRoot)
+
+ cmd := traceInitTestCmd(t, tc.args...)
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.want)
+ })
+ }
+}
diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go
index 2b16b373f..5cc06719f 100644
--- a/app/cli/cmd/trace_init_test.go
+++ b/app/cli/cmd/trace_init_test.go
@@ -286,7 +286,7 @@ func TestWriteTraceInitStateFailureLeavesNothing(t *testing.T) {
require.NoError(t, err)
cfg := &traceInitConfig{project: "a-project", workflow: defaultTraceWorkflow, saveProject: true}
- require.Error(t, writeTraceInitState(cfg, repoRoot, gitDir),
+ require.Error(t, writeTraceInitState(cfg, repoRoot, gitDir, false),
"a trace directory that cannot be created has to fail the step")
// The step is not atomic on its own: it saved the configuration before it
diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go
index 998351966..041ac085e 100644
--- a/app/cli/cmd/trace_run.go
+++ b/app/cli/cmd/trace_run.go
@@ -39,7 +39,15 @@ wrapped command exits, so a session never leaks setup into the next one.
The workflow is created before the command runs. That part is best-effort: the
session runs either way and the attestation creates the workflow if this did
-not, except with --contract, which trace run cannot honor later.`
+not, except with --contract, which trace run cannot honor later.
+
+With --export , the session is written to a local directory instead of
+being pushed as an attestation. This runs the same assembly a push does — the
+same redaction, the same spec, skill, and image materials, each stored under
+its content digest — but makes no call to the control plane and needs no
+credentials. The identity flags are not required in this mode. Use it to
+inspect the exact evidence a session would send. --no-redact turns off secret
+redaction for a trusted local run.`
// newTraceRunCmd creates the `trace run` subcommand.
func newTraceRunCmd() *cobra.Command {
@@ -48,6 +56,8 @@ func newTraceRunCmd() *cobra.Command {
workflowFlag string
versionFlag string
contractFlag string
+ exportFlag string
+ noRedactFlag bool
claudeFlag bool
cursorFlag bool
opencodeFlag bool
@@ -95,18 +105,26 @@ func newTraceRunCmd() *cobra.Command {
return fmt.Errorf("reading --org flag: %w", err)
}
- // MarkFlagRequired and Changed() only check that a flag was
- // passed, so an empty value like --workflow "" would slip
- // through and silently fall back to the default workflow.
- // Validate the actual values here.
- if organization == "" {
- return fmt.Errorf("--org is required for trace run")
- }
- if projectFlag == "" {
- return fmt.Errorf("--project is required for trace run")
+ // Export mode writes the evidence to disk and talks to no control
+ // plane, so it needs no attestation identity. The attestation path
+ // still does: MarkFlagRequired and Changed() only check that a flag
+ // was passed, so an empty value like --workflow "" would slip
+ // through and silently fall back to the default workflow. Validate
+ // the actual values here.
+ if exportFlag == "" {
+ if organization == "" {
+ return fmt.Errorf("--org is required for trace run")
+ }
+ if projectFlag == "" {
+ return fmt.Errorf("--project is required for trace run")
+ }
+ if workflowFlag == "" {
+ return fmt.Errorf("--workflow is required for trace run")
+ }
}
- if workflowFlag == "" {
- return fmt.Errorf("--workflow is required for trace run")
+
+ if noRedactFlag && exportFlag == "" {
+ return fmt.Errorf("--no-redact only applies with --export")
}
contractName, contractRequired := config.ResolveContract(contractFlag)
@@ -124,20 +142,21 @@ func newTraceRunCmd() *cobra.Command {
ContractRequired: contractRequired,
ActionOpts: ActionOpts,
CLIVersion: Version,
+ ExportDir: exportFlag,
+ NoRedact: noRedactFlag,
})
},
}
- cmd.Flags().StringVar(&projectFlag, "project", "", "chainloop project name (required; .chainloop.yml is ignored)")
- cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name used for trace attestations (required; .chainloop.yml is ignored)")
+ cmd.Flags().StringVar(&projectFlag, "project", "", "chainloop project name (required unless --export; .chainloop.yml is ignored)")
+ cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name used for trace attestations (required unless --export; .chainloop.yml is ignored)")
cmd.Flags().StringVar(&versionFlag, "version", "", "chainloop project version (optional; defaults to the latest version)")
cmd.Flags().StringVar(&contractFlag, "contract", "", traceContractFlagDesc)
+ cmd.Flags().StringVar(&exportFlag, "export", "", "write the session evidence to this directory instead of pushing an attestation; no control plane, credentials, or network needed")
+ cmd.Flags().BoolVar(&noRedactFlag, "no-redact", false, "disable secret redaction in --export mode; for a trusted local run only, as the output can then hold secrets")
cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)")
cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks")
cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks")
- _ = cmd.MarkFlagRequired("project")
- _ = cmd.MarkFlagRequired("workflow")
-
return cmd
}
diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md
index 9ecf4b11a..dd97edfb9 100644
--- a/app/cli/documentation/cli-reference.md
+++ b/app/cli/documentation/cli-reference.md
@@ -3257,6 +3257,55 @@ Options inherited from parent commands
-y, --yes Skip confirmation
```
+### chainloop trace export
+
+Write the recorded AI coding session evidence to a local directory
+
+Synopsis
+
+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.
+
+```
+chainloop trace export [flags]
+```
+
+Options
+
+```
+-h, --help help for export
+--no-redact disable secret redaction; for a trusted local run only, as the output can then hold secrets
+```
+
+Options inherited from parent commands
+
+```
+--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443")
+--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA)
+-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml)
+--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443")
+--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA)
+--debug Enable debug/verbose logging mode
+-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE)
+--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE)
+-n, --org string organization name
+-o, --output string Output format, valid options are json and table (default "table")
+-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN
+-y, --yes Skip confirmation
+```
+
### chainloop trace help
Help about any command
@@ -3322,6 +3371,13 @@ 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.
+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.
+
```
chainloop trace init [flags]
```
@@ -3333,6 +3389,7 @@ Options
--contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise)
--cursor install Cursor hooks
-h, --help help for init
+--local set up backend-free local mode: no control plane, no login, no workflow; export the evidence to disk with 'chainloop trace export'
--opencode install opencode hooks
--project string chainloop project name
--require-trace block pushes when attestation fails for AI-assisted commits
@@ -3376,6 +3433,14 @@ The workflow is created before the command runs. That part is best-effort: the
session runs either way and the attestation creates the workflow if this did
not, except with --contract, which trace run cannot honor later.
+With --export , the session is written to a local directory instead of
+being pushed as an attestation. This runs the same assembly a push does — the
+same redaction, the same spec, skill, and image materials, each stored under
+its content digest — but makes no call to the control plane and needs no
+credentials. The identity flags are not required in this mode. Use it to
+inspect the exact evidence a session would send. --no-redact turns off secret
+redaction for a trusted local run.
+
```
chainloop trace run -- [args...] [flags]
```
@@ -3386,11 +3451,13 @@ Options
--claude install Claude Code hooks (default when no provider flag is set)
--contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise)
--cursor install Cursor hooks
+--export string write the session evidence to this directory instead of pushing an attestation; no control plane, credentials, or network needed
-h, --help help for run
+--no-redact disable secret redaction in --export mode; for a trusted local run only, as the output can then hold secrets
--opencode install opencode hooks
---project string chainloop project name (required; .chainloop.yml is ignored)
+--project string chainloop project name (required unless --export; .chainloop.yml is ignored)
--version string chainloop project version (optional; defaults to the latest version)
---workflow string chainloop workflow name used for trace attestations (required; .chainloop.yml is ignored)
+--workflow string chainloop workflow name used for trace attestations (required unless --export; .chainloop.yml is ignored)
```
Options inherited from parent commands
diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md
index 6072463ae..5b6370ad1 100644
--- a/app/cli/internal/trace/README.md
+++ b/app/cli/internal/trace/README.md
@@ -100,6 +100,8 @@ How the sessions are linked to the commits and attested.
|---------|---------------|-------------|--------|--------------|------------|
| Commit trailer | Each commit gets a `Chainloop-Trace-Sessions` trailer with the sessions that contributed to it. | Yes | Yes | Yes | Yes |
| Attestation at push | The `pre-push` hook attests the sessions of the pushed commits. | Yes | Yes | Yes | Yes |
+| Evidence export to disk | `chainloop trace run --export ` runs the same assembly a push does — the same redaction, pointers, and spec, skill, and image materials — and writes the result to a local directory, each material under its content digest, with no control plane and no attestation. The exported evidence equals what a push would upload, apart from the signature and the attestation wrapper. `chainloop trace export ` writes the same evidence for the sessions the installed hooks have already recorded. `--no-redact` keeps secrets, for a trusted local run. | Yes | Yes | Yes | Yes |
+| Local mode | `chainloop trace init --local` sets the repository up without a control plane: it installs the same hooks but creates no workflow and needs no login. The `pre-push` hook then pushes no attestation; the evidence is read off 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 attestation off for teammates. | Yes | Yes | Yes | Yes |
## How each agent is hooked in
diff --git a/app/cli/internal/trace/state/local_test.go b/app/cli/internal/trace/state/local_test.go
new file mode 100644
index 000000000..e732e8f25
--- /dev/null
+++ b/app/cli/internal/trace/state/local_test.go
@@ -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 state
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestLocalMarker(t *testing.T) {
+ store := NewGitStore(t.TempDir())
+
+ require.False(t, store.IsLocal(), "a fresh store is not in local mode")
+
+ require.NoError(t, store.MarkLocal())
+ require.True(t, store.IsLocal(), "MarkLocal puts the store in local mode")
+
+ // Marking twice is idempotent.
+ require.NoError(t, store.MarkLocal())
+ require.True(t, store.IsLocal())
+
+ require.NoError(t, store.RemoveLocal())
+ require.False(t, store.IsLocal(), "RemoveLocal clears local mode")
+
+ // Removing when absent is a no-op, not an error.
+ require.NoError(t, store.RemoveLocal())
+}
+
+// TestLocalMarkerSurvivesWipe guards the property the pre-push skip relies on:
+// a per-push wipe keeps the clone in local mode, so attestation stays off after
+// the first session as well.
+func TestLocalMarkerSurvivesWipe(t *testing.T) {
+ store := NewGitStore(t.TempDir())
+
+ require.NoError(t, store.MarkLocal())
+ require.NoError(t, store.WipeTraceDir())
+
+ require.True(t, store.IsLocal(), "local mode survives WipeTraceDir")
+}
diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go
index f73564214..709a84405 100644
--- a/app/cli/internal/trace/state/state.go
+++ b/app/cli/internal/trace/state/state.go
@@ -39,11 +39,18 @@ const (
// detect trace-run mode without depending on environment-variable
// inheritance from the agent.
traceRunActiveFile = "run-active"
- traceDirSessions = "sessions"
- traceDirRaw = "raw"
- traceDirCommits = "commits"
- traceDirSnapshots = "snapshots"
- traceDirAILines = "ai-lines"
+ // traceLocalFile is the sentinel created by `chainloop trace init --local`.
+ // It marks this clone as a backend-free install: sessions are recorded as
+ // usual, the pre-push hook pushes no attestation, and the evidence is
+ // written to disk with `chainloop trace export`. It lives in the per-clone
+ // trace state, never in the shared .chainloop.yml, so one developer's local
+ // setup does not turn off attestation for the whole team.
+ traceLocalFile = "local"
+ traceDirSessions = "sessions"
+ traceDirRaw = "raw"
+ traceDirCommits = "commits"
+ traceDirSnapshots = "snapshots"
+ traceDirAILines = "ai-lines"
// traceDirSpecRedactions holds the redacted copies of each session's
// spec files, so a later push of the session does not scan an unchanged
// file again. It lives for the session, not for one push.
@@ -371,6 +378,30 @@ func (s *Store) RemoveTraceInitialized() error {
return removeIfExists(filepath.Join(s.traceDirPath(), traceInitializedFile))
}
+// IsLocal reports whether this clone was set up with `trace init --local`, so
+// the pre-push hook pushes no attestation and the evidence is exported to disk
+// instead.
+func (s *Store) IsLocal() bool {
+ _, err := os.Stat(filepath.Join(s.traceDirPath(), traceLocalFile))
+ return err == nil
+}
+
+// MarkLocal creates the sentinel that puts this clone in backend-free local
+// mode. It survives WipeTraceDir, so the mode outlasts the per-push cleanup, and
+// is cleared only when the trace directory is removed (trace uninstall).
+func (s *Store) MarkLocal() error {
+ base := s.traceDirPath()
+ if err := os.MkdirAll(base, 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(base, traceLocalFile), nil, 0600)
+}
+
+// RemoveLocal removes the local-mode sentinel.
+func (s *Store) RemoveLocal() error {
+ return removeIfExists(filepath.Join(s.traceDirPath(), traceLocalFile))
+}
+
// MarkTraceRunActive marks the trace state as belonging to a running
// `chainloop trace run` session. Removed by ClearTraceRunActive when
// the wrapped command exits.
diff --git a/app/cli/pkg/action/trace_export.go b/app/cli/pkg/action/trace_export.go
new file mode 100644
index 000000000..627384fcd
--- /dev/null
+++ b/app/cli/pkg/action/trace_export.go
@@ -0,0 +1,328 @@
+//
+// 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 action
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git"
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state"
+ v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
+ "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
+ "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
+ "github.com/chainloop-dev/chainloop/pkg/casclient"
+ "github.com/rs/zerolog"
+)
+
+// manifestFileName is the index a reader opens first in an export directory.
+const manifestFileName = "manifest.json"
+
+// materialsDirName holds every material, each file named by its content digest
+// so a reader and a test find it from the reference in the evidence record.
+const materialsDirName = "materials"
+
+// RunTraceExportOpts configures a disk export of the current session evidence.
+type RunTraceExportOpts struct {
+ // OutDir is the directory the evidence is written to. Required.
+ OutDir string
+ // NoRedact disables secret redaction. Off by default: the export redacts
+ // secrets exactly as a push does. The opt-out is for a trusted local run,
+ // and the output can then hold secrets.
+ NoRedact bool
+ // Mode records how the session was driven, as one of the
+ // aicodingsession.Mode* constants. Empty means ModeCoding.
+ Mode string
+}
+
+// RunTraceExport runs the same push-time assembly that RunTracePush drives, but
+// writes the resulting evidence to a local directory instead of uploading a
+// signed attestation. It makes no call to the control plane and needs no
+// credentials: every material is crafted offline, with an inline CAS backend,
+// so the evidence record and the material digests equal what a push would
+// upload (spec issue export-to-disk, R-001/R-002).
+//
+// It returns the directory the evidence was written to, or an empty string when
+// there was nothing to export.
+func RunTraceExport(ctx context.Context, log zerolog.Logger, opts RunTraceExportOpts) (string, error) {
+ if opts.OutDir == "" {
+ return "", fmt.Errorf("output directory is required")
+ }
+
+ store, repoRoot, err := state.Locate()
+ if err != nil {
+ return "", err
+ }
+
+ sessionMode := aicodingsession.ResolveMode(opts.Mode)
+ log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot).
+ Str("out_dir", opts.OutDir).Bool("no_redact", opts.NoRedact).Str("mode", sessionMode).
+ Msg("trace export invoked")
+
+ allCommits, err := store.LoadAllCommitRecords()
+ if err != nil {
+ return "", fmt.Errorf("load commit records: %w", err)
+ }
+
+ gitClient := tracegit.NewGoGitClient()
+ allCommits = filterCurrentBranchCommits(gitClient, repoRoot, allCommits, log)
+
+ var aiCommits []*state.CommitRecord
+ for _, c := range allCommits {
+ if len(c.SessionIDs) > 0 {
+ aiCommits = append(aiCommits, c)
+ }
+ }
+
+ sessionRecords, err := store.LoadAllSessionRecords()
+ if err != nil {
+ log.Debug().Err(err).Msg("could not load session records")
+ }
+
+ // Export always assembles every recorded session, with or without commits:
+ // the point is to inspect what a session would send before it sends it.
+ sessionCommits := sessionCommitGroups(aiCommits, sessionRecords, true)
+ if len(sessionCommits) == 0 {
+ log.Info().Msg("no AI coding sessions recorded, nothing to export")
+
+ return "", nil
+ }
+
+ sessions := buildSessionEvidence(ctx, store, repoRoot, gitClient, sessionCommits, sessionRecords, sessionMode, log)
+ if len(sessions) == 0 {
+ log.Debug().Msg("no session evidence could be generated")
+
+ return "", nil
+ }
+
+ if err := os.MkdirAll(opts.OutDir, 0o700); err != nil {
+ return "", fmt.Errorf("create output directory: %w", err)
+ }
+
+ sink := newDiskMaterialSink(opts.OutDir, opts.NoRedact, log)
+
+ exported, _ := attachSessionEvidence(ctx, sink, store, sessions, log, opts.NoRedact)
+ if len(exported) == 0 {
+ log.Debug().Msg("no evidence could be exported")
+
+ return "", nil
+ }
+
+ if err := sink.writeManifest(!opts.NoRedact); err != nil {
+ return "", fmt.Errorf("write manifest: %w", err)
+ }
+
+ log.Info().Str("dir", opts.OutDir).Int("sessions", len(exported)).Int("materials", len(sink.materials)).
+ Msg("Session evidence exported")
+
+ return opts.OutDir, nil
+}
+
+// diskMaterialSink implements specMaterialAdder by crafting each material
+// offline and writing its content under its content digest. It is the export
+// counterpart of AttestationExecutor: it runs the identical crafting — same
+// redaction, same content-addressed digest — but stores the result on disk
+// rather than uploading it, so the exported evidence matches a push byte for
+// byte apart from the signature and the attestation wrapper (R-002, R-003).
+type diskMaterialSink struct {
+ outDir string
+ noRedact bool
+ logger zerolog.Logger
+ // backend has no uploader, so uploadAndCraft stores each artifact inline
+ // and never reaches the network.
+ backend *casclient.CASBackend
+ materials []exportedMaterial
+}
+
+// exportedMaterial is one material's entry in the manifest.
+type exportedMaterial struct {
+ // Name is the material name the assembly allocated for it.
+ Name string `json:"name"`
+ // Kind is the material type (e.g. EVIDENCE, CHAINLOOP_AI_CODING_SESSION).
+ Kind string `json:"kind"`
+ // Digest is the content digest the material is stored under, sha256:.
+ Digest string `json:"digest"`
+ // File is the path of the material's content, relative to the export
+ // directory.
+ File string `json:"file"`
+ // Annotations are the attestation annotations the material carries.
+ Annotations map[string]string `json:"annotations,omitempty"`
+}
+
+// exportManifest is the top-level index of an export directory.
+type exportManifest struct {
+ GeneratedAt string `json:"generated_at"`
+ // Signed and IsAttestation are always false: the export is an unsigned
+ // evidence set, not a signed in-toto attestation, so it proves nothing on
+ // its own. The index declares this so a reader never mistakes the export for
+ // an attestation (spec issue-3582, R-006).
+ Signed bool `json:"signed"`
+ IsAttestation bool `json:"is_attestation"`
+ Redaction bool `json:"redaction"`
+ Sessions []string `json:"sessions"`
+ Materials []exportedMaterial `json:"materials"`
+}
+
+func newDiskMaterialSink(outDir string, noRedact bool, logger zerolog.Logger) *diskMaterialSink {
+ return &diskMaterialSink{
+ outDir: outDir,
+ noRedact: noRedact,
+ logger: logger,
+ backend: &casclient.CASBackend{Name: "chainloop-trace-export", Uploader: nil},
+ }
+}
+
+// AddMaterial crafts the file at path as a material of the given kind and writes
+// its stored content under its digest. It returns the digest, which the
+// assembly records in the evidence in place of the content, exactly as the push
+// records the CAS digest.
+func (s *diskMaterialSink) AddMaterial(ctx context.Context, name, path, kind string, annotations map[string]string) (string, error) {
+ kindValue, ok := v1.CraftingSchema_Material_MaterialType_value[kind]
+ if !ok {
+ return "", fmt.Errorf("unknown material kind %q", kind)
+ }
+
+ schema := &v1.CraftingSchema_Material{
+ Type: v1.CraftingSchema_Material_MaterialType(kindValue),
+ Name: name,
+ }
+
+ res, err := materials.Craft(ctx, schema, path, s.backend, nil, &s.logger, &materials.CraftingOpts{
+ SkipSecretRedaction: s.noRedact,
+ })
+ if err != nil {
+ return "", fmt.Errorf("crafting %s material: %w", kind, err)
+ }
+
+ artifact := res.Material.GetArtifact()
+ if artifact == nil {
+ return "", fmt.Errorf("material kind %q produced no artifact to export", kind)
+ }
+
+ content, err := materialContent(artifact.GetContent(), res.Content, path)
+ if err != nil {
+ return "", err
+ }
+
+ file, err := s.writeMaterial(artifact.GetDigest(), content)
+ if err != nil {
+ return "", err
+ }
+
+ s.materials = append(s.materials, exportedMaterial{
+ Name: name,
+ Kind: kind,
+ Digest: artifact.GetDigest(),
+ File: file,
+ Annotations: mergeAnnotations(res.Material.GetAnnotations(), annotations),
+ })
+
+ return artifact.GetDigest(), nil
+}
+
+// materialContent returns the bytes to store for a material. An inline craft
+// records the stored content on the artifact; a redacting crafter also returns
+// it on the result. When neither is set the material was stored verbatim, so
+// the file on disk is the content.
+func materialContent(inline, override []byte, path string) ([]byte, error) {
+ switch {
+ case inline != nil:
+ return inline, nil
+ case override != nil:
+ return override, nil
+ default:
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading material content: %w", err)
+ }
+
+ return content, nil
+ }
+}
+
+// writeMaterial stores content under materials/ and returns the path
+// relative to the export directory. Materials are content-addressed, so a
+// repeated digest is the same bytes and the rewrite is a harmless no-op.
+func (s *diskMaterialSink) writeMaterial(digest string, content []byte) (string, error) {
+ // A ':' in the digest is awkward on some filesystems, so the stored file
+ // uses '-'; the digest itself is kept intact in the manifest.
+ rel := filepath.Join(materialsDirName, strings.ReplaceAll(digest, ":", "-"))
+ abs := filepath.Join(s.outDir, rel)
+
+ if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil {
+ return "", fmt.Errorf("create materials directory: %w", err)
+ }
+
+ if err := os.WriteFile(abs, content, 0o600); err != nil {
+ return "", fmt.Errorf("write material %s: %w", digest, err)
+ }
+
+ return rel, nil
+}
+
+// writeManifest writes the top-level index of the export. redaction records
+// whether secrets were stripped, so a reader can tell a safe-to-share export
+// from a raw one.
+func (s *diskMaterialSink) writeManifest(redaction bool) error {
+ sessionKind := v1.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION.String()
+
+ var sessions []string
+ for _, m := range s.materials {
+ if m.Kind == sessionKind {
+ sessions = append(sessions, m.Name)
+ }
+ }
+
+ manifest := exportManifest{
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ Signed: false,
+ IsAttestation: false,
+ Redaction: redaction,
+ Sessions: sessions,
+ Materials: s.materials,
+ }
+
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode manifest: %w", err)
+ }
+
+ return os.WriteFile(filepath.Join(s.outDir, manifestFileName), data, 0o600)
+}
+
+// mergeAnnotations combines the annotations the crafter set on a material with
+// the ones the assembly passed for it. The assembly's annotations win on a
+// conflict, matching the attestation-add path that layers them on last.
+func mergeAnnotations(crafted, extra map[string]string) map[string]string {
+ if len(crafted) == 0 && len(extra) == 0 {
+ return nil
+ }
+
+ merged := make(map[string]string, len(crafted)+len(extra))
+ for k, v := range crafted {
+ merged[k] = v
+ }
+ for k, v := range extra {
+ merged[k] = v
+ }
+
+ return merged
+}
diff --git a/app/cli/pkg/action/trace_export_test.go b/app/cli/pkg/action/trace_export_test.go
new file mode 100644
index 000000000..a057f59cf
--- /dev/null
+++ b/app/cli/pkg/action/trace_export_test.go
@@ -0,0 +1,206 @@
+//
+// 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 action
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude"
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/spec"
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state"
+ "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
+ "github.com/rs/zerolog"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// cleanSession builds the evidence of a session that wrote one spec file into
+// the spec folder and holds a copy of it in its transcript. It takes a text so
+// the same session can be built with or without a secret, and returns a fresh
+// value each call so two assembly passes do not share mutable state.
+func cleanSession(t *testing.T, sessionID, ticketText string) []sessionEvidence {
+ t.Helper()
+
+ ticketPath := "/repo/.chainloop/specs/" + sessionID + "/ticket.md"
+ raw := []json.RawMessage{
+ json.RawMessage(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_w","name":"Write","input":{"file_path":` + strconv.Quote(ticketPath) + `,"content":` + strconv.Quote(ticketText) + `}}]}}`),
+ json.RawMessage(`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_w","content":"File created"}]},"toolUseResult":{"type":"create","filePath":` + strconv.Quote(ticketPath) + `,"content":` + strconv.Quote(ticketText) + `,"structuredPatch":[],"originalFile":null}}`),
+ }
+
+ evidence := aicodingsession.NewEvidence(aicodingsession.Data{
+ SchemaVersion: "0.1",
+ Agent: aicodingsession.Agent{Name: "claude-code"},
+ Session: aicodingsession.Session{ID: sessionID, StartedAt: "2026-10-08T10:00:00Z", DurationSeconds: 42},
+ RawSession: map[string][]json.RawMessage{mainStream: raw},
+ })
+
+ ticket := specCapture(t, "ticket.md", ticketText, "2026-10-08T10:00:00Z")
+
+ return []sessionEvidence{{
+ sessionID: sessionID,
+ provider: claude.New(),
+ evidence: evidence,
+ specs: []spec.Capture{ticket},
+ }}
+}
+
+// TestRunTraceExportMatchesPush covers R-002 and R-003: the disk export runs the
+// same assembly a push does, so it produces the same materials with the same
+// content digests, and every material resolves from its digest.
+func TestRunTraceExportMatchesPush(t *testing.T) {
+ const sessionID = "abc123-session"
+ ctx := context.Background()
+ ticketText := "---\nkind: ticket\nuri: https://tracker.example.com/ENG-7\ntitle: Add export\n---\n\nThe export flow is missing.\n"
+
+ store := state.NewGitStore(t.TempDir())
+
+ // The export writes each material to disk.
+ outDir := t.TempDir()
+ sink := newDiskMaterialSink(outDir, false, zerolog.Nop())
+ diskAttested, _ := attachSessionEvidence(ctx, sink, store, cleanSession(t, sessionID, ticketText), zerolog.Nop(), false)
+ require.NoError(t, sink.writeManifest(true))
+ require.Equal(t, []string{sessionID}, diskAttested)
+
+ // The same assembly, as a push records it: realDigests makes the fake adder
+ // hash each material's content exactly as the CAS does.
+ fake := &fakeMaterialAdder{realDigests: true}
+ fakeAttested, _ := attachSessionEvidence(ctx, fake, store, cleanSession(t, sessionID, ticketText), zerolog.Nop(), false)
+ require.Equal(t, []string{sessionID}, fakeAttested)
+
+ // R-002: the same materials, in the same order, under the same digests.
+ sinkNames := make([]string, 0, len(sink.materials))
+ for _, m := range sink.materials {
+ sinkNames = append(sinkNames, m.Name)
+ }
+ require.Equal(t, fake.names(), sinkNames)
+ for _, m := range sink.materials {
+ assert.Equal(t, fake.byName(m.Name).digest, m.Digest, "digest of material %q", m.Name)
+ }
+
+ // R-003: every material is stored under its digest, and its bytes hash back
+ // to it.
+ for _, m := range sink.materials {
+ content, err := os.ReadFile(filepath.Join(outDir, m.File))
+ require.NoError(t, err, "material %q is stored", m.Name)
+ sum := sha256.Sum256(content)
+ assert.Equal(t, m.Digest, "sha256:"+hex.EncodeToString(sum[:]), "content of %q matches its digest", m.Name)
+ }
+
+ // R-002: the exported evidence record equals the one the push would upload.
+ sessionName := evidenceName(sessionID)
+ sessionDigest := fake.byName(sessionName).digest
+ exported, err := os.ReadFile(filepath.Join(outDir, materialsDirName, strings.ReplaceAll(sessionDigest, ":", "-")))
+ require.NoError(t, err)
+ assert.JSONEq(t, fake.byName(sessionName).content, string(exported))
+
+ // The manifest lists the session and resolves every material it names.
+ var manifest exportManifest
+ data, err := os.ReadFile(filepath.Join(outDir, manifestFileName))
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(data, &manifest))
+ // R-006: the index declares the export is unsigned and not an attestation.
+ assert.False(t, manifest.Signed)
+ assert.False(t, manifest.IsAttestation)
+ assert.True(t, manifest.Redaction)
+ assert.Contains(t, manifest.Sessions, sessionName)
+ require.NotEmpty(t, manifest.Materials)
+ for _, m := range manifest.Materials {
+ assert.FileExists(t, filepath.Join(outDir, m.File))
+ }
+}
+
+// TestRunTraceExportRedaction covers R-004: the export redacts secrets by
+// default, and the opt-out keeps them.
+func TestRunTraceExportRedaction(t *testing.T) {
+ const sessionID = "redact1-session"
+ // Assembled from fragments so that nothing credential-shaped is committed.
+ const pat = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf"
+ ticketText := "---\nkind: ticket\nuri: https://tracker.example.com/ENG-9\ntitle: Wire up auth\n---\n\nUse the token " + pat + " for now.\n"
+
+ store := state.NewGitStore(t.TempDir())
+ ctx := context.Background()
+
+ containsSecret := func(t *testing.T, dir string) bool {
+ t.Helper()
+ found := false
+ require.NoError(t, filepath.WalkDir(filepath.Join(dir, materialsDirName), func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if strings.Contains(string(content), pat) {
+ found = true
+ }
+
+ return nil
+ }))
+
+ return found
+ }
+
+ t.Run("redaction on by default removes the secret", func(t *testing.T) {
+ outDir := t.TempDir()
+ sink := newDiskMaterialSink(outDir, false, zerolog.Nop())
+ attested, _ := attachSessionEvidence(ctx, sink, store, cleanSession(t, sessionID, ticketText), zerolog.Nop(), false)
+ require.Equal(t, []string{sessionID}, attested)
+
+ assert.False(t, containsSecret(t, outDir), "no exported material holds the secret")
+ })
+
+ t.Run("opt-out keeps the secret", func(t *testing.T) {
+ outDir := t.TempDir()
+ sink := newDiskMaterialSink(outDir, true, zerolog.Nop())
+ attested, _ := attachSessionEvidence(ctx, sink, store, cleanSession(t, sessionID, ticketText), zerolog.Nop(), true)
+ require.Equal(t, []string{sessionID}, attested)
+
+ assert.True(t, containsSecret(t, outDir), "the raw export holds the secret")
+ })
+}
+
+// TestRunTraceExportNoSessions covers R-001 in the degenerate case: with no
+// recorded sessions the export touches no control plane and writes nothing.
+func TestRunTraceExportNoSessions(t *testing.T) {
+ repoDir := initTempGitRepo(t)
+ require.NoError(t, state.NewGitStore(filepath.Join(repoDir, ".git")).InitTraceDir())
+ t.Chdir(repoDir)
+
+ outDir := filepath.Join(t.TempDir(), "evidence")
+ got, err := RunTraceExport(context.Background(), zerolog.Nop(), RunTraceExportOpts{OutDir: outDir})
+ require.NoError(t, err)
+ assert.Empty(t, got)
+ assert.NoDirExists(t, outDir)
+}
+
+// TestRunTraceExportRequiresOutDir rejects an empty output directory.
+func TestRunTraceExportRequiresOutDir(t *testing.T) {
+ _, err := RunTraceExport(context.Background(), zerolog.Nop(), RunTraceExportOpts{})
+ require.Error(t, err)
+}
diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go
index 7d1ffd869..a566fde86 100644
--- a/app/cli/pkg/action/trace_hook_handler.go
+++ b/app/cli/pkg/action/trace_hook_handler.go
@@ -533,9 +533,27 @@ type RunTracePushOpts struct {
func HandlePrePushHook(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts) error {
drainPushStdin()
+ // A clone set up with `trace init --local` talks to no control plane, so the
+ // pre-push hook has no attestation to push. The session is still recorded by
+ // the other hooks; its evidence is written to disk with `trace export`.
+ if isLocalTrace() {
+ log.Debug().Msg("local trace mode: skipping attestation on push; use `chainloop trace export` to write the evidence to disk")
+
+ return nil
+ }
+
return RunTracePush(ctx, log, opts)
}
+// isLocalTrace reports whether the clone the hook runs in was set up with
+// `trace init --local`. A failure to locate the trace state is treated as "not
+// local": the normal attestation path then runs and reports any real problem,
+// rather than this silently swallowing the push.
+func isLocalTrace() bool {
+ store, _, err := state.Locate()
+ return err == nil && store.IsLocal()
+}
+
// PrePushFailure decides what a failed pre-push hook does. The managed
// pre-push script propagates the hook's exit status to git. When
// requireTrace is true, it returns the error so that the git push is
@@ -611,33 +629,13 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts
return nil
}
- sessionCommits := make(map[string][]*state.CommitRecord)
- for _, c := range aiCommits {
- for _, sid := range c.SessionIDs {
- sessionCommits[sid] = append(sessionCommits[sid], c)
- }
- }
- for _, commits := range sessionCommits {
- sort.Slice(commits, func(i, j int) bool {
- return commits[i].Timestamp < commits[j].Timestamp
- })
- }
-
sessionRecords, err := store.LoadAllSessionRecords()
if err != nil {
log.Debug().Err(err).Msg("could not load session records")
}
+ sessionCommits := sessionCommitGroups(aiCommits, sessionRecords, opts.AllowEmpty)
if len(aiCommits) == 0 {
- for sid, rec := range sessionRecords {
- // A session that has ended without contributing a commit to this
- // branch has nothing new to say; attesting it on every later push
- // republishes the same stale evidence.
- if !rec.Active {
- continue
- }
- sessionCommits[sid] = nil
- }
if len(sessionCommits) == 0 {
log.Info().Msg("no AI coding sessions recorded, skipping attestation")
@@ -698,7 +696,7 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts
// Add evidence for each session: its spec materials first, so that the
// session material can record their digests, then the session itself.
- attestedSessions, attestedSpecs := attachSessionEvidence(ctx, executor, store, sessions, log)
+ attestedSessions, attestedSpecs := attachSessionEvidence(ctx, executor, store, sessions, log, false)
if len(attestedSessions) == 0 {
log.Debug().Msg("no evidence successfully added, resetting attestation")
@@ -769,6 +767,37 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts
return nil
}
+// sessionCommitGroups groups the AI commits by the sessions that contributed
+// to them, each group sorted oldest first. When there are no AI commits and
+// allowEmpty is set, it falls back to the active sessions, so their evidence is
+// still assembled: the `trace run` and export paths attest a session even when
+// it produced no commit. A session that has ended without a commit is left out,
+// as attesting it on every later push would republish the same stale evidence.
+func sessionCommitGroups(aiCommits []*state.CommitRecord, sessionRecords map[string]*state.SessionRecord, allowEmpty bool) map[string][]*state.CommitRecord {
+ groups := make(map[string][]*state.CommitRecord)
+ for _, c := range aiCommits {
+ for _, sid := range c.SessionIDs {
+ groups[sid] = append(groups[sid], c)
+ }
+ }
+ for _, commits := range groups {
+ sort.Slice(commits, func(i, j int) bool {
+ return commits[i].Timestamp < commits[j].Timestamp
+ })
+ }
+
+ if len(aiCommits) == 0 && allowEmpty {
+ for sid, rec := range sessionRecords {
+ if rec == nil || !rec.Active {
+ continue
+ }
+ groups[sid] = nil
+ }
+ }
+
+ return groups
+}
+
// sessionEvidence is the evidence of one session before it is added to the
// attestation. It is written out only once the attestation exists, because it
// records the digests of the spec and skill materials added to it first.
@@ -900,7 +929,7 @@ func buildSessionEvidence(ctx context.Context, store *state.Store, repoRoot stri
// its spec and skill materials first, so that the session material can record
// their digests, then the session itself. It returns the sessions that were
// added, and the spec files that each of them stored.
-func attachSessionEvidence(ctx context.Context, adder specMaterialAdder, store *state.Store, sessions []sessionEvidence, log zerolog.Logger) (attestedSessions []string, attestedSpecs map[string][]string) {
+func attachSessionEvidence(ctx context.Context, adder specMaterialAdder, store *state.Store, sessions []sessionEvidence, log zerolog.Logger, noRedact bool) (attestedSessions []string, attestedSpecs map[string][]string) {
attestedSessions = make([]string, 0, len(sessions))
attestedSpecs = make(map[string][]string, len(sessions))
// One allocator for the whole attestation: names taken from the start of
@@ -908,7 +937,7 @@ func attachSessionEvidence(ctx context.Context, adder specMaterialAdder, store *
// replace an earlier material.
names := materials.NewNameAllocator(nil)
for _, se := range sessions {
- redactor := newSpecRedactor(store.SpecRedactionDir(se.sessionID))
+ redactor := newSpecRedactor(store.SpecRedactionDir(se.sessionID), noRedact)
entries, warnings, stored, sources := attachSpecs(ctx, adder, redactor, names, se.sessionID, se.specs, log)
se.evidence.Data.Warnings = append(se.evidence.Data.Warnings, warnings...)
diff --git a/app/cli/pkg/action/trace_hook_local_test.go b/app/cli/pkg/action/trace_hook_local_test.go
new file mode 100644
index 000000000..d4fcd59ce
--- /dev/null
+++ b/app/cli/pkg/action/trace_hook_local_test.go
@@ -0,0 +1,66 @@
+//
+// 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 action
+
+import (
+ "os"
+ "testing"
+
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state"
+ "github.com/rs/zerolog"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsLocalTrace(t *testing.T) {
+ t.Run("a normal clone is not local", func(t *testing.T) {
+ _, gitDir := initGitRepo(t)
+ require.NoError(t, state.NewGitStore(gitDir).InitTraceDir())
+
+ assert.False(t, isLocalTrace())
+ })
+
+ t.Run("a clone marked local is local", func(t *testing.T) {
+ _, gitDir := initGitRepo(t)
+ store := state.NewGitStore(gitDir)
+ require.NoError(t, store.InitTraceDir())
+ require.NoError(t, store.MarkLocal())
+
+ assert.True(t, isLocalTrace())
+ })
+}
+
+// TestHandlePrePushHookSkipsLocal checks the pre-push hook pushes no attestation
+// in local mode. ActionOpts is nil on purpose: were the hook to reach the
+// attestation path with a session to push, building the executor would fail, so
+// a clean return is only possible because the local marker short-circuits it.
+func TestHandlePrePushHookSkipsLocal(t *testing.T) {
+ _, gitDir := initGitRepo(t)
+ store := state.NewGitStore(gitDir)
+ require.NoError(t, store.InitTraceDir())
+ require.NoError(t, store.MarkLocal())
+
+ // drainPushStdin reads to EOF; give it an empty input so the test never
+ // blocks on the terminal.
+ devNull, err := os.Open(os.DevNull)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = devNull.Close() })
+ origStdin := os.Stdin
+ os.Stdin = devNull
+ t.Cleanup(func() { os.Stdin = origStdin })
+
+ require.NoError(t, HandlePrePushHook(t.Context(), zerolog.Nop(), RunTracePushOpts{AllowEmpty: true}))
+}
diff --git a/app/cli/pkg/action/trace_pasted_images_test.go b/app/cli/pkg/action/trace_pasted_images_test.go
index 1d77972bd..f24690d14 100644
--- a/app/cli/pkg/action/trace_pasted_images_test.go
+++ b/app/cli/pkg/action/trace_pasted_images_test.go
@@ -126,7 +126,7 @@ func TestAttachSessionEvidencePastedImages(t *testing.T) {
provider: claude.New(),
evidence: evidence,
specs: captures,
- }}, zerolog.Nop())
+ }}, zerolog.Nop(), false)
require.Equal(t, []string{sessionID}, attested)
var got aicodingsession.Evidence
@@ -233,7 +233,7 @@ func TestPastedImagesNeedAFinder(t *testing.T) {
adder := &fakeMaterialAdder{realDigests: true}
attachSessionEvidence(context.Background(), adder, state.NewGitStore(t.TempDir()), []sessionEvidence{{
sessionID: "c0ffee-session", provider: cursor.New(), evidence: evidence,
- }}, zerolog.Nop())
+ }}, zerolog.Nop(), false)
assert.Equal(t, []string{"ai-coding-session-c0ffee"}, adder.names())
assert.Contains(t, adder.byName("ai-coding-session-c0ffee").content, strconv.Quote(base64.StdEncoding.EncodeToString(img)))
diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go
index 6797e4c2f..66b8af6a7 100644
--- a/app/cli/pkg/action/trace_run.go
+++ b/app/cli/pkg/action/trace_run.go
@@ -43,6 +43,23 @@ func (e *SubprocessExitError) Error() string {
return fmt.Sprintf("%s exited with status %d", e.Command, e.ExitCode)
}
+// subprocessError maps the error from running the wrapped command into the
+// value TraceRun returns: a SubprocessExitError that carries the exit code so
+// the CLI can propagate it, a wrapped error for a failure to launch, or nil on
+// success.
+func subprocessError(command string, err error) error {
+ if err == nil {
+ return nil
+ }
+
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ return &SubprocessExitError{Command: command, ExitCode: exitErr.ExitCode()}
+ }
+
+ return fmt.Errorf("run %s: %w", command, err)
+}
+
// TraceRunOpts configures a single TraceRun invocation.
type TraceRunOpts struct {
// Store owns the chainloop-trace state directory, parented by the
@@ -76,11 +93,20 @@ type TraceRunOpts struct {
ContractRequired bool
// ActionOpts is the root command's initialized options, used to build
- // the attestation executor. Required.
+ // the attestation executor. Required, except in export mode, which talks
+ // to no control plane.
ActionOpts *ActionsOpts
// CLIVersion is the bare CLI version recorded in the attestation
// predicate.
CLIVersion string
+
+ // ExportDir, when set, runs the session in export mode: once the wrapped
+ // command exits, the evidence is written to this directory instead of
+ // being pushed as an attestation. No control plane, no credentials, no
+ // network. The identity fields above are then ignored.
+ ExportDir string
+ // NoRedact disables secret redaction in export mode. Ignored otherwise.
+ NoRedact bool
}
// TraceRun wraps a single-shot agent invocation: it cleans any prior
@@ -98,20 +124,25 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error
return fmt.Errorf("no trace providers selected")
}
- var authExecOpts []ExecutorOption
- if opts.Organization != "" {
- authExecOpts = append(authExecOpts, WithForcedOrganization(opts.Organization))
- }
- executor, err := NewAttestationExecutor(opts.ActionOpts, opts.CLIVersion, authExecOpts...)
- if err != nil {
- return err
- }
- prepErr := prepareTraceRunWorkflow(ctx, log, executor, opts)
- if err := executor.Close(); err != nil {
- log.Debug().Err(err).Msg("closing auth-check executor")
- }
- if prepErr != nil {
- return prepErr
+ // Export mode talks to no control plane, so it skips the credential check
+ // and the up-front workflow creation: the session is recorded and written
+ // to disk either way.
+ if opts.ExportDir == "" {
+ var authExecOpts []ExecutorOption
+ if opts.Organization != "" {
+ authExecOpts = append(authExecOpts, WithForcedOrganization(opts.Organization))
+ }
+ executor, err := NewAttestationExecutor(opts.ActionOpts, opts.CLIVersion, authExecOpts...)
+ if err != nil {
+ return err
+ }
+ prepErr := prepareTraceRunWorkflow(ctx, log, executor, opts)
+ if err := executor.Close(); err != nil {
+ log.Debug().Err(err).Msg("closing auth-check executor")
+ }
+ if prepErr != nil {
+ return prepErr
+ }
}
// Snapshot the agent settings files before we touch anything else
@@ -179,13 +210,33 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error
log.Debug().Strs("command", opts.Command).Msg("running wrapped command")
- if err := sub.Run(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
- return &SubprocessExitError{Command: opts.Command[0], ExitCode: exitErr.ExitCode()}
+ runErr := sub.Run()
+
+ // Export mode writes the evidence whatever the agent's exit status was: the
+ // point is to inspect what the session produced, and a session the user
+ // interrupted (a non-zero exit) still has evidence worth reading. The push
+ // path, in contrast, attests only a clean run.
+ if opts.ExportDir != "" {
+ log.Debug().Msg("wrapped command finished; exporting session evidence to disk")
+
+ dir, err := RunTraceExport(ctx, log, RunTraceExportOpts{
+ OutDir: opts.ExportDir,
+ NoRedact: opts.NoRedact,
+ Mode: aicodingsession.ModeGeneric,
+ })
+ switch {
+ case err != nil:
+ // An export failure must not mask the agent's own exit status.
+ log.Warn().Err(err).Msg("could not export session evidence")
+ case dir == "":
+ log.Warn().Msg("no AI coding session was recorded, so nothing was exported")
}
- return fmt.Errorf("run %s: %w", opts.Command[0], err)
+ return subprocessError(opts.Command[0], runErr)
+ }
+
+ if runErr != nil {
+ return subprocessError(opts.Command[0], runErr)
}
log.Debug().Msg("wrapped command completed; attesting session")
diff --git a/app/cli/pkg/action/trace_run_test.go b/app/cli/pkg/action/trace_run_test.go
index 31d2d40fa..acee303ff 100644
--- a/app/cli/pkg/action/trace_run_test.go
+++ b/app/cli/pkg/action/trace_run_test.go
@@ -16,7 +16,9 @@
package action
import (
+ "errors"
"os"
+ "os/exec"
"path/filepath"
"testing"
@@ -28,6 +30,29 @@ import (
"github.com/stretchr/testify/require"
)
+// TestSubprocessError maps the wrapped command's result the way both the export
+// and the push paths rely on: a clean run is no error, a non-zero exit carries
+// its code so the CLI can propagate it (and `trace run --export` exports first),
+// and a launch failure is wrapped.
+func TestSubprocessError(t *testing.T) {
+ assert.NoError(t, subprocessError("claude", nil))
+
+ // A real non-zero exit, produced by running a command that fails.
+ exitErr := exec.Command("sh", "-c", "exit 7").Run()
+ require.Error(t, exitErr)
+ got := subprocessError("claude", exitErr)
+ var sub *SubprocessExitError
+ require.ErrorAs(t, got, &sub)
+ assert.Equal(t, 7, sub.ExitCode)
+ assert.Equal(t, "claude", sub.Command)
+
+ // A failure that is not an exit (e.g. the binary could not be launched) is
+ // wrapped, not turned into a SubprocessExitError.
+ launchErr := subprocessError("claude", errors.New("exec: not found"))
+ require.Error(t, launchErr)
+ assert.NotErrorAs(t, launchErr, &sub)
+}
+
func TestTraceRunOwnsState(t *testing.T) {
t.Run("clean repo", func(t *testing.T) {
assert.True(t, traceRunOwnsState(state.NewGitStore(t.TempDir())))
diff --git a/app/cli/pkg/action/trace_skills_scenario_test.go b/app/cli/pkg/action/trace_skills_scenario_test.go
index 8ddc867c6..1c519bdf1 100644
--- a/app/cli/pkg/action/trace_skills_scenario_test.go
+++ b/app/cli/pkg/action/trace_skills_scenario_test.go
@@ -156,7 +156,7 @@ func TestSkillScenario(t *testing.T) {
require.Len(t, sessions, 1)
adder := &fakeMaterialAdder{realDigests: true}
- attested, _ := attachSessionEvidence(ctx, adder, store, sessions, zerolog.Nop())
+ attested, _ := attachSessionEvidence(ctx, adder, store, sessions, zerolog.Nop(), false)
require.Equal(t, []string{sessionID}, attested)
// Each skill gives its definition and its package, then the session
diff --git a/app/cli/pkg/action/trace_skills_test.go b/app/cli/pkg/action/trace_skills_test.go
index b644306ee..dd03b0048 100644
--- a/app/cli/pkg/action/trace_skills_test.go
+++ b/app/cli/pkg/action/trace_skills_test.go
@@ -133,7 +133,7 @@ func TestCaptureSkillLoads(t *testing.T) {
assert.Equal(t, []string{`skill "asd-ste100" changed after its first use; the evidence holds the skill as it was at the first use`}, warnings)
adder := &fakeMaterialAdder{}
- _, _ = attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, skills, zerolog.Nop())
+ _, _ = attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, skills, zerolog.Nop())
require.Len(t, adder.added, 2)
assert.Equal(t, "as it ran", adder.added[0].content)
})
@@ -246,7 +246,7 @@ func TestAttachSkills(t *testing.T) {
}, trace.SkillUse{Name: steSkillName, FirstUsedAt: "2026-10-07T10:14:51Z", ByModel: 2, ByUser: 1, InSubagents: 1})
adder := &fakeMaterialAdder{}
- entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
+ entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
assert.Empty(t, warnings)
require.Len(t, adder.added, 2)
@@ -292,7 +292,7 @@ func TestAttachSkills(t *testing.T) {
s := stored(t, map[string]string{skill.DefinitionFile: "# Big", "model.bin": string(big)}, trace.SkillUse{Name: "big", FirstUsedAt: skillTenOClock, ByModel: 1})
adder := &fakeMaterialAdder{}
- entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
+ entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
assert.Equal(t, []string{`the package of skill "big" is larger than 5 MB and was not uploaded`}, warnings)
require.Len(t, adder.added, 1)
require.Len(t, entries, 1)
@@ -304,7 +304,7 @@ func TestAttachSkills(t *testing.T) {
s := stored(t, map[string]string{skill.DefinitionFile: skillDoc}, trace.SkillUse{Name: steSkillName, FirstUsedAt: skillTenOClock, ByModel: 1})
adder := &fakeMaterialAdder{failOn: map[string]bool{"spec-7412a0-skill-asd-ste100-pkg": true}}
- entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
+ entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
assert.Equal(t, []string{`the package of skill "asd-ste100" was not uploaded`}, warnings)
require.Len(t, entries, 1)
assert.Empty(t, entries[0].Metadata.PackageDigest)
@@ -314,7 +314,7 @@ func TestAttachSkills(t *testing.T) {
s := stored(t, map[string]string{skill.DefinitionFile: skillDoc}, trace.SkillUse{Name: steSkillName, FirstUsedAt: skillTenOClock, ByModel: 1})
adder := &fakeMaterialAdder{failOn: map[string]bool{"spec-7412a0-skill-asd-ste100": true}}
- entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
+ entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
assert.Empty(t, entries)
assert.Equal(t, []string{`skill "asd-ste100" was not recorded`}, warnings)
})
@@ -323,7 +323,7 @@ func TestAttachSkills(t *testing.T) {
s := stored(t, map[string]string{skill.DefinitionFile: skillDoc}, trace.SkillUse{Name: "superpowers:brainstorming", FirstUsedAt: skillTenOClock, ByModel: 1})
adder := &fakeMaterialAdder{}
- _, _ = attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
+ _, _ = attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop())
require.Len(t, adder.added, 2)
assert.Equal(t, "spec-7412a0-skill-superpowers-brainstorming", adder.added[0].name)
assert.Equal(t, "superpowers-brainstorming.tar.gz", adder.added[1].fileName)
diff --git a/app/cli/pkg/action/trace_spec_materials.go b/app/cli/pkg/action/trace_spec_materials.go
index 39c3d01aa..ad99703ee 100644
--- a/app/cli/pkg/action/trace_spec_materials.go
+++ b/app/cli/pkg/action/trace_spec_materials.go
@@ -233,14 +233,19 @@ func addSpecMaterial(ctx context.Context, adder specMaterialAdder, dir, name, se
type specRedactor struct {
// dir holds one redacted copy per file, named by the SHA-256 of the file
// as the agent wrote it. It is dropped when the session ends.
- dir string
+ dir string
+ // skip returns each file unchanged, cache and scanner both bypassed: the
+ // opt-out is for a trusted local export, which can then hold secrets
+ // (R-004).
+ skip bool
redact func(ctx context.Context, doc []byte) ([]byte, error)
}
// newSpecRedactor returns a redactor that uses the secret scanner of the
-// session material and keeps its copies under dir.
-func newSpecRedactor(dir string) *specRedactor {
- return &specRedactor{dir: dir, redact: func(ctx context.Context, doc []byte) ([]byte, error) {
+// session material and keeps its copies under dir. When skip is set the
+// redactor returns each file unchanged.
+func newSpecRedactor(dir string, skip bool) *specRedactor {
+ return &specRedactor{dir: dir, skip: skip, redact: func(ctx context.Context, doc []byte) ([]byte, error) {
redacted, _, err := aicodingsession.RedactSpecText(ctx, string(doc))
return []byte(redacted), err
}}
@@ -251,6 +256,12 @@ func newSpecRedactor(dir string) *specRedactor {
// header included, so the source address is redacted like the text. A copy
// that cannot be stored costs the next push a scan, never this one its spec.
func (r *specRedactor) Redact(ctx context.Context, doc []byte) ([]byte, error) {
+ // The opt-out bypasses the cache too: a cached copy from an earlier
+ // redacting run would otherwise override it.
+ if r.skip {
+ return doc, nil
+ }
+
sum := sha256.Sum256(doc)
path := filepath.Join(r.dir, hex.EncodeToString(sum[:]))
diff --git a/app/cli/pkg/action/trace_spec_materials_test.go b/app/cli/pkg/action/trace_spec_materials_test.go
index 54f7e0d5a..f1d4d4d8c 100644
--- a/app/cli/pkg/action/trace_spec_materials_test.go
+++ b/app/cli/pkg/action/trace_spec_materials_test.go
@@ -124,7 +124,7 @@ func TestAttachSpecs(t *testing.T) {
t.Run("each capture becomes an EVIDENCE material and a reference", func(t *testing.T) {
adder := &fakeMaterialAdder{}
- entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{ticket, plan}, zerolog.Nop())
+ entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{ticket, plan}, zerolog.Nop())
assert.Empty(t, warnings)
require.Len(t, adder.added, 2)
@@ -166,7 +166,7 @@ func TestAttachSpecs(t *testing.T) {
local := specCapture(t, "spec-foo.md", "---\nkind: document\nuri: docs/specs/foo.md\n---\n"+fileContent, "2026-10-02T15:30:00Z")
local.SourceDigest = pointer.Digest([]byte(fileContent))
- _, warnings, _, sources := attachSpecs(context.Background(), &fakeMaterialAdder{}, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{local, ticket}, zerolog.Nop())
+ _, warnings, _, sources := attachSpecs(context.Background(), &fakeMaterialAdder{}, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{local, ticket}, zerolog.Nop())
assert.Empty(t, warnings)
assert.Equal(t, pointer.Sources{
@@ -182,7 +182,7 @@ func TestAttachSpecs(t *testing.T) {
"---\nkind: ticket\nuri: https://tracker.example.com/issue/1?token="+pat+"\n---\nconfigured with the token "+pat+" and still a 401",
"2026-09-16T10:12:03Z")
- entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{withSecret}, zerolog.Nop())
+ entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{withSecret}, zerolog.Nop())
assert.Empty(t, warnings)
require.Len(t, adder.added, 1)
@@ -227,7 +227,7 @@ func TestAttachSpecs(t *testing.T) {
"---\nkind: ticket\nrole: task\ntitle: \"ENG-1234: Add an export button\"\ndescription: The ticket that the session implements.\n---\nthe ticket",
"2026-09-16T10:12:03Z")
- entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{described, plan}, zerolog.Nop())
+ entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{described, plan}, zerolog.Nop())
assert.Empty(t, warnings)
require.Len(t, adder.added, 2)
@@ -267,7 +267,7 @@ func TestAttachSpecs(t *testing.T) {
MetaRaw: []byte("role: reference\ntitle: PFM-2: [WIP] fix #12\n"),
}
- entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{hash, colons, image}, zerolog.Nop())
+ entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{hash, colons, image}, zerolog.Nop())
assert.Empty(t, warnings)
require.Len(t, adder.added, 3)
@@ -303,7 +303,7 @@ func TestAttachSpecs(t *testing.T) {
"---\nkind: ticket\ntitle: token "+pat+" fails\ndescription: uses "+pat+"\n---\nthe ticket",
"2026-09-16T10:12:03Z")
- entries, _, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{withSecret}, zerolog.Nop())
+ entries, _, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{withSecret}, zerolog.Nop())
require.Len(t, entries, 1)
assert.Equal(t, "token [CHAINLOOP_TRACE_REDACTED:github-pat] fails", entries[0].Title)
@@ -321,7 +321,7 @@ func TestAttachSpecs(t *testing.T) {
MetaRaw: []byte("role: reference\ntitle: mockup for " + pat + "\ndescription: Where the button goes.\n"),
}
- entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{image}, zerolog.Nop())
+ entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{image}, zerolog.Nop())
assert.Empty(t, warnings)
require.Len(t, adder.added, 1, "the companion file is no material of its own")
@@ -357,7 +357,7 @@ func TestAttachSpecs(t *testing.T) {
t.Run("a failed add drops that entry only, and says so", func(t *testing.T) {
adder := &fakeMaterialAdder{failOn: map[string]bool{"spec-7412a0-ticket-pfm-7289": true}}
- entries, warnings, stored, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{ticket, plan}, zerolog.Nop())
+ entries, warnings, stored, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{ticket, plan}, zerolog.Nop())
// A reference to a material that is not in the attestation would point
// nowhere, so the entry goes with it.
@@ -383,7 +383,7 @@ func TestAttachSpecs(t *testing.T) {
c := ticket
c.FileName = "design-2.md"
- entries, _, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{a, b, c}, zerolog.Nop())
+ entries, _, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{a, b, c}, zerolog.Nop())
require.Len(t, entries, 3)
names := []string{adder.added[0].name, adder.added[1].name, adder.added[2].name}
@@ -398,8 +398,8 @@ func TestAttachSpecs(t *testing.T) {
first := specCapture(t, "ticket.md", "the first ticket", "2026-09-16T10:12:03Z")
second := specCapture(t, "ticket.md", "the second ticket", "2026-09-16T10:12:04Z")
- _, _, _, _ = attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), names, "ses_3AbC9x", []spec.Capture{first}, zerolog.Nop())
- _, _, _, _ = attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), names, "ses_3AbZ7y", []spec.Capture{second}, zerolog.Nop())
+ _, _, _, _ = attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), names, "ses_3AbC9x", []spec.Capture{first}, zerolog.Nop())
+ _, _, _, _ = attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), names, "ses_3AbZ7y", []spec.Capture{second}, zerolog.Nop())
require.Len(t, adder.added, 2)
assert.NotEqual(t, adder.added[0].name, adder.added[1].name, "one attestation must never hold two materials under one name")
@@ -480,7 +480,7 @@ func TestSpecRedactor(t *testing.T) {
})
t.Run("the default scanner removes secrets", func(t *testing.T) {
- got, err := newSpecRedactor(t.TempDir()).Redact(context.Background(), []byte("the token "+pat+" fails"))
+ got, err := newSpecRedactor(t.TempDir(), false).Redact(context.Background(), []byte("the token "+pat+" fails"))
require.NoError(t, err)
assert.Equal(t, "the token [CHAINLOOP_TRACE_REDACTED:github-pat] fails", string(got))
})
diff --git a/app/cli/pkg/action/trace_spec_pointers_test.go b/app/cli/pkg/action/trace_spec_pointers_test.go
index ab04557eb..d4108564d 100644
--- a/app/cli/pkg/action/trace_spec_pointers_test.go
+++ b/app/cli/pkg/action/trace_spec_pointers_test.go
@@ -82,7 +82,7 @@ func TestAttachSessionEvidenceSpecPointers(t *testing.T) {
provider: claude.New(),
evidence: evidence,
specs: []spec.Capture{ticket, screenshot},
- }}, zerolog.Nop())
+ }}, zerolog.Nop(), false)
require.Equal(t, []string{sessionID}, attested)
ticketDigest := adder.byName("spec-7c1e2d-ticket-eng-1234").digest
@@ -187,7 +187,7 @@ func TestAttachSessionEvidenceLocalSourcePointers(t *testing.T) {
provider: claude.New(),
evidence: evidence,
specs: captures,
- }}, zerolog.Nop())
+ }}, zerolog.Nop(), false)
require.Equal(t, []string{sessionID}, attested)
material := adder.byName("spec-9a8b7c-design-note")