From 84074f62217574d7ee6087fadbc1ea7b71889a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Thu, 8 Oct 2026 22:50:41 +0000 Subject: [PATCH 1/4] feat(trace): export session evidence to disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an export mode to `chainloop trace run` that runs the full push-time evidence assembly and writes the result to a local directory instead of pushing 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 exported evidence record and the material digests equal what a push would upload, apart from the signature and the attestation wrapper. Each material is written under its content digest, with a top-level manifest indexing them, so a reader or a test finds each one by its reference in the evidence record. Redaction stays on by default, matching a push; --no-redact opts out for a trusted local run. Usage: `chainloop trace run --export -- `. Signed-off-by: Matías Insaurralde --- app/cli/cmd/trace_run.go | 53 ++- app/cli/documentation/cli-reference.md | 14 +- app/cli/internal/trace/README.md | 1 + app/cli/pkg/action/trace_export.go | 320 ++++++++++++++++++ app/cli/pkg/action/trace_export_test.go | 203 +++++++++++ app/cli/pkg/action/trace_hook_handler.go | 59 ++-- .../pkg/action/trace_pasted_images_test.go | 4 +- app/cli/pkg/action/trace_run.go | 58 +++- .../pkg/action/trace_skills_scenario_test.go | 2 +- app/cli/pkg/action/trace_skills_test.go | 12 +- app/cli/pkg/action/trace_spec_materials.go | 19 +- .../pkg/action/trace_spec_materials_test.go | 24 +- .../pkg/action/trace_spec_pointers_test.go | 4 +- 13 files changed, 688 insertions(+), 85 deletions(-) create mode 100644 app/cli/pkg/action/trace_export.go create mode 100644 app/cli/pkg/action/trace_export_test.go 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 6072463ae..03a20f2c9 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -100,6 +100,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..8fa7d494f 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -76,11 +76,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 +107,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 @@ -188,6 +202,20 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error return fmt.Errorf("run %s: %w", opts.Command[0], err) } + if opts.ExportDir != "" { + log.Debug().Msg("wrapped command completed; exporting session evidence to disk") + + if _, err := RunTraceExport(ctx, log, RunTraceExportOpts{ + OutDir: opts.ExportDir, + NoRedact: opts.NoRedact, + Mode: aicodingsession.ModeGeneric, + }); err != nil { + return fmt.Errorf("export session evidence: %w", err) + } + + return nil + } + log.Debug().Msg("wrapped command completed; attesting session") return RunTracePush(ctx, log, RunTracePushOpts{ 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") From 52eda65c4a3a41f431e149030833e6486ebf81f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Thu, 8 Oct 2026 23:26:05 +0000 Subject: [PATCH 2/4] fix(trace): export session evidence even when the agent exits non-zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In export mode, `chainloop trace run` only wrote the evidence when the wrapped agent exited with status zero, so a session the user interrupted (for example by closing the agent with Ctrl-C) produced no output and no message. Export is for inspecting what a session produced, so it now runs whatever the agent's exit status was, warns when no session was recorded or the export failed, and still propagates the agent's exit code. Signed-off-by: Matías Insaurralde --- app/cli/pkg/action/trace_run.go | 49 ++++++++++++++++++++-------- app/cli/pkg/action/trace_run_test.go | 25 ++++++++++++++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index 8fa7d494f..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 @@ -193,27 +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()} - } - - return fmt.Errorf("run %s: %w", opts.Command[0], err) - } + 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 completed; exporting session evidence to disk") + log.Debug().Msg("wrapped command finished; exporting session evidence to disk") - if _, err := RunTraceExport(ctx, log, RunTraceExportOpts{ + dir, err := RunTraceExport(ctx, log, RunTraceExportOpts{ OutDir: opts.ExportDir, NoRedact: opts.NoRedact, Mode: aicodingsession.ModeGeneric, - }); err != nil { - return fmt.Errorf("export session evidence: %w", err) + }) + 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 nil + 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()))) From bc8bfd3986f895591d4199966430a830a3efea1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Tue, 13 Oct 2026 00:31:33 -0300 Subject: [PATCH 3/4] feat(trace): add local mode init and standalone export command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `trace init --local`, which sets up AI coding session tracing without a control plane: it installs the hooks but creates no workflow and needs no login. In local mode the pre-push hook pushes no attestation, and the evidence is written to disk instead. Local mode is marked in the per-clone trace state, not in the shared .chainloop.yml, so it does not turn attestation off for other clones. Add `trace export `, which writes the evidence of the sessions the hooks have already recorded to a local directory, reusing the same assembly a push does. Refs #3582 Chainloop-Trace-Sessions: d139ac66-b95f-4920-ac38-12997996037d Signed-off-by: Matías Insaurralde --- app/cli/cmd/trace.go | 1 + app/cli/cmd/trace_export.go | 74 +++++++++++++ app/cli/cmd/trace_export_test.go | 53 ++++++++++ app/cli/cmd/trace_init.go | 110 ++++++++++++++++++-- app/cli/cmd/trace_init_local_test.go | 103 ++++++++++++++++++ app/cli/cmd/trace_init_test.go | 2 +- app/cli/documentation/cli-reference.md | 57 ++++++++++ app/cli/internal/trace/README.md | 3 +- app/cli/internal/trace/state/local_test.go | 53 ++++++++++ app/cli/internal/trace/state/state.go | 41 +++++++- app/cli/pkg/action/trace_hook_handler.go | 18 ++++ app/cli/pkg/action/trace_hook_local_test.go | 66 ++++++++++++ 12 files changed, 563 insertions(+), 18 deletions(-) create mode 100644 app/cli/cmd/trace_export.go create mode 100644 app/cli/cmd/trace_export_test.go create mode 100644 app/cli/cmd/trace_init_local_test.go create mode 100644 app/cli/internal/trace/state/local_test.go create mode 100644 app/cli/pkg/action/trace_hook_local_test.go 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/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 8cca17c51..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 diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 03a20f2c9..5b6370ad1 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -100,7 +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. `--no-redact` keeps secrets, for a trusted local run. | 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_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index b47745c4b..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 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})) +} From 83300a5cbb2cadcc4015fe6ff6314417fe43db9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Tue, 13 Oct 2026 01:04:45 -0300 Subject: [PATCH 4/4] feat(trace): declare the disk export is unsigned in its manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export manifest now carries signed and is_attestation fields, both false, so a reader cannot mistake the on-disk evidence for a signed attestation. This satisfies the manifest declaration the spec requires (issue-3582 R-006). Refs #3582 Chainloop-Trace-Sessions: d139ac66-b95f-4920-ac38-12997996037d Signed-off-by: Matías Insaurralde --- app/cli/pkg/action/trace_export.go | 24 ++++++++++++++++-------- app/cli/pkg/action/trace_export_test.go | 3 +++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/cli/pkg/action/trace_export.go b/app/cli/pkg/action/trace_export.go index 764519a8f..627384fcd 100644 --- a/app/cli/pkg/action/trace_export.go +++ b/app/cli/pkg/action/trace_export.go @@ -169,10 +169,16 @@ type exportedMaterial struct { // 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"` + 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 { @@ -286,10 +292,12 @@ func (s *diskMaterialSink) writeManifest(redaction bool) error { } manifest := exportManifest{ - GeneratedAt: time.Now().UTC().Format(time.RFC3339), - Redaction: redaction, - Sessions: sessions, - Materials: s.materials, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Signed: false, + IsAttestation: false, + Redaction: redaction, + Sessions: sessions, + Materials: s.materials, } data, err := json.MarshalIndent(manifest, "", " ") diff --git a/app/cli/pkg/action/trace_export_test.go b/app/cli/pkg/action/trace_export_test.go index c58771b0e..a057f59cf 100644 --- a/app/cli/pkg/action/trace_export_test.go +++ b/app/cli/pkg/action/trace_export_test.go @@ -120,6 +120,9 @@ func TestRunTraceExportMatchesPush(t *testing.T) { 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)