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..8cca17c51 100644
--- a/app/cli/documentation/cli-reference.md
+++ b/app/cli/documentation/cli-reference.md
@@ -3376,6 +3376,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 +3394,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 65db6664a..df4ae4040 100644
--- a/app/cli/internal/trace/README.md
+++ b/app/cli/internal/trace/README.md
@@ -99,6 +99,7 @@ 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. `--no-redact` keeps secrets, for a trusted local run. | Yes | Yes | Yes | Yes |
## How each agent is hooked in
diff --git a/app/cli/pkg/action/trace_export.go b/app/cli/pkg/action/trace_export.go
new file mode 100644
index 000000000..764519a8f
--- /dev/null
+++ b/app/cli/pkg/action/trace_export.go
@@ -0,0 +1,320 @@
+//
+// 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"`
+ 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),
+ 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..c58771b0e
--- /dev/null
+++ b/app/cli/pkg/action/trace_export_test.go
@@ -0,0 +1,203 @@
+//
+// 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))
+ 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..b47745c4b 100644
--- a/app/cli/pkg/action/trace_hook_handler.go
+++ b/app/cli/pkg/action/trace_hook_handler.go
@@ -611,33 +611,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 +678,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 +749,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 +911,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 +919,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_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")