diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 998351966..041ac085e 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -39,7 +39,15 @@ wrapped command exits, so a session never leaks setup into the next one. The workflow is created before the command runs. That part is best-effort: the session runs either way and the attestation creates the workflow if this did -not, except with --contract, which trace run cannot honor later.` +not, except with --contract, which trace run cannot honor later. + +With --export , the session is written to a local directory instead of +being pushed as an attestation. This runs the same assembly a push does — the +same redaction, the same spec, skill, and image materials, each stored under +its content digest — but makes no call to the control plane and needs no +credentials. The identity flags are not required in this mode. Use it to +inspect the exact evidence a session would send. --no-redact turns off secret +redaction for a trusted local run.` // newTraceRunCmd creates the `trace run` subcommand. func newTraceRunCmd() *cobra.Command { @@ -48,6 +56,8 @@ func newTraceRunCmd() *cobra.Command { workflowFlag string versionFlag string contractFlag string + exportFlag string + noRedactFlag bool claudeFlag bool cursorFlag bool opencodeFlag bool @@ -95,18 +105,26 @@ func newTraceRunCmd() *cobra.Command { return fmt.Errorf("reading --org flag: %w", err) } - // MarkFlagRequired and Changed() only check that a flag was - // passed, so an empty value like --workflow "" would slip - // through and silently fall back to the default workflow. - // Validate the actual values here. - if organization == "" { - return fmt.Errorf("--org is required for trace run") - } - if projectFlag == "" { - return fmt.Errorf("--project is required for trace run") + // Export mode writes the evidence to disk and talks to no control + // plane, so it needs no attestation identity. The attestation path + // still does: MarkFlagRequired and Changed() only check that a flag + // was passed, so an empty value like --workflow "" would slip + // through and silently fall back to the default workflow. Validate + // the actual values here. + if exportFlag == "" { + if organization == "" { + return fmt.Errorf("--org is required for trace run") + } + if projectFlag == "" { + return fmt.Errorf("--project is required for trace run") + } + if workflowFlag == "" { + return fmt.Errorf("--workflow is required for trace run") + } } - if workflowFlag == "" { - return fmt.Errorf("--workflow is required for trace run") + + if noRedactFlag && exportFlag == "" { + return fmt.Errorf("--no-redact only applies with --export") } contractName, contractRequired := config.ResolveContract(contractFlag) @@ -124,20 +142,21 @@ func newTraceRunCmd() *cobra.Command { ContractRequired: contractRequired, ActionOpts: ActionOpts, CLIVersion: Version, + ExportDir: exportFlag, + NoRedact: noRedactFlag, }) }, } - cmd.Flags().StringVar(&projectFlag, "project", "", "chainloop project name (required; .chainloop.yml is ignored)") - cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name used for trace attestations (required; .chainloop.yml is ignored)") + cmd.Flags().StringVar(&projectFlag, "project", "", "chainloop project name (required unless --export; .chainloop.yml is ignored)") + cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name used for trace attestations (required unless --export; .chainloop.yml is ignored)") cmd.Flags().StringVar(&versionFlag, "version", "", "chainloop project version (optional; defaults to the latest version)") cmd.Flags().StringVar(&contractFlag, "contract", "", traceContractFlagDesc) + cmd.Flags().StringVar(&exportFlag, "export", "", "write the session evidence to this directory instead of pushing an attestation; no control plane, credentials, or network needed") + cmd.Flags().BoolVar(&noRedactFlag, "no-redact", false, "disable secret redaction in --export mode; for a trusted local run only, as the output can then hold secrets") cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)") cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks") cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks") - _ = cmd.MarkFlagRequired("project") - _ = cmd.MarkFlagRequired("workflow") - return cmd } diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 9ecf4b11a..8cca17c51 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -3376,6 +3376,14 @@ The workflow is created before the command runs. That part is best-effort: the session runs either way and the attestation creates the workflow if this did not, except with --contract, which trace run cannot honor later. +With --export , the session is written to a local directory instead of +being pushed as an attestation. This runs the same assembly a push does — the +same redaction, the same spec, skill, and image materials, each stored under +its content digest — but makes no call to the control plane and needs no +credentials. The identity flags are not required in this mode. Use it to +inspect the exact evidence a session would send. --no-redact turns off secret +redaction for a trusted local run. + ``` chainloop trace run -- [args...] [flags] ``` @@ -3386,11 +3394,13 @@ Options --claude install Claude Code hooks (default when no provider flag is set) --contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks +--export string write the session evidence to this directory instead of pushing an attestation; no control plane, credentials, or network needed -h, --help help for run +--no-redact disable secret redaction in --export mode; for a trusted local run only, as the output can then hold secrets --opencode install opencode hooks ---project string chainloop project name (required; .chainloop.yml is ignored) +--project string chainloop project name (required unless --export; .chainloop.yml is ignored) --version string chainloop project version (optional; defaults to the latest version) ---workflow string chainloop workflow name used for trace attestations (required; .chainloop.yml is ignored) +--workflow string chainloop workflow name used for trace attestations (required unless --export; .chainloop.yml is ignored) ``` Options inherited from parent commands diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 65db6664a..df4ae4040 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -99,6 +99,7 @@ How the sessions are linked to the commits and attested. |---------|---------------|-------------|--------|--------------|------------| | Commit trailer | Each commit gets a `Chainloop-Trace-Sessions` trailer with the sessions that contributed to it. | Yes | Yes | Yes | Yes | | Attestation at push | The `pre-push` hook attests the sessions of the pushed commits. | Yes | Yes | Yes | Yes | +| Evidence export to disk | `chainloop trace run --export ` runs the same assembly a push does — the same redaction, pointers, and spec, skill, and image materials — and writes the result to a local directory, each material under its content digest, with no control plane and no attestation. The exported evidence equals what a push would upload, apart from the signature and the attestation wrapper. `--no-redact` keeps secrets, for a trusted local run. | Yes | Yes | Yes | Yes | ## How each agent is hooked in diff --git a/app/cli/pkg/action/trace_export.go b/app/cli/pkg/action/trace_export.go new file mode 100644 index 000000000..764519a8f --- /dev/null +++ b/app/cli/pkg/action/trace_export.go @@ -0,0 +1,320 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/rs/zerolog" +) + +// manifestFileName is the index a reader opens first in an export directory. +const manifestFileName = "manifest.json" + +// materialsDirName holds every material, each file named by its content digest +// so a reader and a test find it from the reference in the evidence record. +const materialsDirName = "materials" + +// RunTraceExportOpts configures a disk export of the current session evidence. +type RunTraceExportOpts struct { + // OutDir is the directory the evidence is written to. Required. + OutDir string + // NoRedact disables secret redaction. Off by default: the export redacts + // secrets exactly as a push does. The opt-out is for a trusted local run, + // and the output can then hold secrets. + NoRedact bool + // Mode records how the session was driven, as one of the + // aicodingsession.Mode* constants. Empty means ModeCoding. + Mode string +} + +// RunTraceExport runs the same push-time assembly that RunTracePush drives, but +// writes the resulting evidence to a local directory instead of uploading a +// signed attestation. It makes no call to the control plane and needs no +// credentials: every material is crafted offline, with an inline CAS backend, +// so the evidence record and the material digests equal what a push would +// upload (spec issue export-to-disk, R-001/R-002). +// +// It returns the directory the evidence was written to, or an empty string when +// there was nothing to export. +func RunTraceExport(ctx context.Context, log zerolog.Logger, opts RunTraceExportOpts) (string, error) { + if opts.OutDir == "" { + return "", fmt.Errorf("output directory is required") + } + + store, repoRoot, err := state.Locate() + if err != nil { + return "", err + } + + sessionMode := aicodingsession.ResolveMode(opts.Mode) + log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot). + Str("out_dir", opts.OutDir).Bool("no_redact", opts.NoRedact).Str("mode", sessionMode). + Msg("trace export invoked") + + allCommits, err := store.LoadAllCommitRecords() + if err != nil { + return "", fmt.Errorf("load commit records: %w", err) + } + + gitClient := tracegit.NewGoGitClient() + allCommits = filterCurrentBranchCommits(gitClient, repoRoot, allCommits, log) + + var aiCommits []*state.CommitRecord + for _, c := range allCommits { + if len(c.SessionIDs) > 0 { + aiCommits = append(aiCommits, c) + } + } + + sessionRecords, err := store.LoadAllSessionRecords() + if err != nil { + log.Debug().Err(err).Msg("could not load session records") + } + + // Export always assembles every recorded session, with or without commits: + // the point is to inspect what a session would send before it sends it. + sessionCommits := sessionCommitGroups(aiCommits, sessionRecords, true) + if len(sessionCommits) == 0 { + log.Info().Msg("no AI coding sessions recorded, nothing to export") + + return "", nil + } + + sessions := buildSessionEvidence(ctx, store, repoRoot, gitClient, sessionCommits, sessionRecords, sessionMode, log) + if len(sessions) == 0 { + log.Debug().Msg("no session evidence could be generated") + + return "", nil + } + + if err := os.MkdirAll(opts.OutDir, 0o700); err != nil { + return "", fmt.Errorf("create output directory: %w", err) + } + + sink := newDiskMaterialSink(opts.OutDir, opts.NoRedact, log) + + exported, _ := attachSessionEvidence(ctx, sink, store, sessions, log, opts.NoRedact) + if len(exported) == 0 { + log.Debug().Msg("no evidence could be exported") + + return "", nil + } + + if err := sink.writeManifest(!opts.NoRedact); err != nil { + return "", fmt.Errorf("write manifest: %w", err) + } + + log.Info().Str("dir", opts.OutDir).Int("sessions", len(exported)).Int("materials", len(sink.materials)). + Msg("Session evidence exported") + + return opts.OutDir, nil +} + +// diskMaterialSink implements specMaterialAdder by crafting each material +// offline and writing its content under its content digest. It is the export +// counterpart of AttestationExecutor: it runs the identical crafting — same +// redaction, same content-addressed digest — but stores the result on disk +// rather than uploading it, so the exported evidence matches a push byte for +// byte apart from the signature and the attestation wrapper (R-002, R-003). +type diskMaterialSink struct { + outDir string + noRedact bool + logger zerolog.Logger + // backend has no uploader, so uploadAndCraft stores each artifact inline + // and never reaches the network. + backend *casclient.CASBackend + materials []exportedMaterial +} + +// exportedMaterial is one material's entry in the manifest. +type exportedMaterial struct { + // Name is the material name the assembly allocated for it. + Name string `json:"name"` + // Kind is the material type (e.g. EVIDENCE, CHAINLOOP_AI_CODING_SESSION). + Kind string `json:"kind"` + // Digest is the content digest the material is stored under, sha256:. + Digest string `json:"digest"` + // File is the path of the material's content, relative to the export + // directory. + File string `json:"file"` + // Annotations are the attestation annotations the material carries. + Annotations map[string]string `json:"annotations,omitempty"` +} + +// exportManifest is the top-level index of an export directory. +type exportManifest struct { + GeneratedAt string `json:"generated_at"` + Redaction bool `json:"redaction"` + Sessions []string `json:"sessions"` + Materials []exportedMaterial `json:"materials"` +} + +func newDiskMaterialSink(outDir string, noRedact bool, logger zerolog.Logger) *diskMaterialSink { + return &diskMaterialSink{ + outDir: outDir, + noRedact: noRedact, + logger: logger, + backend: &casclient.CASBackend{Name: "chainloop-trace-export", Uploader: nil}, + } +} + +// AddMaterial crafts the file at path as a material of the given kind and writes +// its stored content under its digest. It returns the digest, which the +// assembly records in the evidence in place of the content, exactly as the push +// records the CAS digest. +func (s *diskMaterialSink) AddMaterial(ctx context.Context, name, path, kind string, annotations map[string]string) (string, error) { + kindValue, ok := v1.CraftingSchema_Material_MaterialType_value[kind] + if !ok { + return "", fmt.Errorf("unknown material kind %q", kind) + } + + schema := &v1.CraftingSchema_Material{ + Type: v1.CraftingSchema_Material_MaterialType(kindValue), + Name: name, + } + + res, err := materials.Craft(ctx, schema, path, s.backend, nil, &s.logger, &materials.CraftingOpts{ + SkipSecretRedaction: s.noRedact, + }) + if err != nil { + return "", fmt.Errorf("crafting %s material: %w", kind, err) + } + + artifact := res.Material.GetArtifact() + if artifact == nil { + return "", fmt.Errorf("material kind %q produced no artifact to export", kind) + } + + content, err := materialContent(artifact.GetContent(), res.Content, path) + if err != nil { + return "", err + } + + file, err := s.writeMaterial(artifact.GetDigest(), content) + if err != nil { + return "", err + } + + s.materials = append(s.materials, exportedMaterial{ + Name: name, + Kind: kind, + Digest: artifact.GetDigest(), + File: file, + Annotations: mergeAnnotations(res.Material.GetAnnotations(), annotations), + }) + + return artifact.GetDigest(), nil +} + +// materialContent returns the bytes to store for a material. An inline craft +// records the stored content on the artifact; a redacting crafter also returns +// it on the result. When neither is set the material was stored verbatim, so +// the file on disk is the content. +func materialContent(inline, override []byte, path string) ([]byte, error) { + switch { + case inline != nil: + return inline, nil + case override != nil: + return override, nil + default: + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading material content: %w", err) + } + + return content, nil + } +} + +// writeMaterial stores content under materials/ and returns the path +// relative to the export directory. Materials are content-addressed, so a +// repeated digest is the same bytes and the rewrite is a harmless no-op. +func (s *diskMaterialSink) writeMaterial(digest string, content []byte) (string, error) { + // A ':' in the digest is awkward on some filesystems, so the stored file + // uses '-'; the digest itself is kept intact in the manifest. + rel := filepath.Join(materialsDirName, strings.ReplaceAll(digest, ":", "-")) + abs := filepath.Join(s.outDir, rel) + + if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil { + return "", fmt.Errorf("create materials directory: %w", err) + } + + if err := os.WriteFile(abs, content, 0o600); err != nil { + return "", fmt.Errorf("write material %s: %w", digest, err) + } + + return rel, nil +} + +// writeManifest writes the top-level index of the export. redaction records +// whether secrets were stripped, so a reader can tell a safe-to-share export +// from a raw one. +func (s *diskMaterialSink) writeManifest(redaction bool) error { + sessionKind := v1.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION.String() + + var sessions []string + for _, m := range s.materials { + if m.Kind == sessionKind { + sessions = append(sessions, m.Name) + } + } + + manifest := exportManifest{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Redaction: redaction, + Sessions: sessions, + Materials: s.materials, + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("encode manifest: %w", err) + } + + return os.WriteFile(filepath.Join(s.outDir, manifestFileName), data, 0o600) +} + +// mergeAnnotations combines the annotations the crafter set on a material with +// the ones the assembly passed for it. The assembly's annotations win on a +// conflict, matching the attestation-add path that layers them on last. +func mergeAnnotations(crafted, extra map[string]string) map[string]string { + if len(crafted) == 0 && len(extra) == 0 { + return nil + } + + merged := make(map[string]string, len(crafted)+len(extra)) + for k, v := range crafted { + merged[k] = v + } + for k, v := range extra { + merged[k] = v + } + + return merged +} diff --git a/app/cli/pkg/action/trace_export_test.go b/app/cli/pkg/action/trace_export_test.go new file mode 100644 index 000000000..c58771b0e --- /dev/null +++ b/app/cli/pkg/action/trace_export_test.go @@ -0,0 +1,203 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/spec" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// cleanSession builds the evidence of a session that wrote one spec file into +// the spec folder and holds a copy of it in its transcript. It takes a text so +// the same session can be built with or without a secret, and returns a fresh +// value each call so two assembly passes do not share mutable state. +func cleanSession(t *testing.T, sessionID, ticketText string) []sessionEvidence { + t.Helper() + + ticketPath := "/repo/.chainloop/specs/" + sessionID + "/ticket.md" + raw := []json.RawMessage{ + json.RawMessage(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_w","name":"Write","input":{"file_path":` + strconv.Quote(ticketPath) + `,"content":` + strconv.Quote(ticketText) + `}}]}}`), + json.RawMessage(`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_w","content":"File created"}]},"toolUseResult":{"type":"create","filePath":` + strconv.Quote(ticketPath) + `,"content":` + strconv.Quote(ticketText) + `,"structuredPatch":[],"originalFile":null}}`), + } + + evidence := aicodingsession.NewEvidence(aicodingsession.Data{ + SchemaVersion: "0.1", + Agent: aicodingsession.Agent{Name: "claude-code"}, + Session: aicodingsession.Session{ID: sessionID, StartedAt: "2026-10-08T10:00:00Z", DurationSeconds: 42}, + RawSession: map[string][]json.RawMessage{mainStream: raw}, + }) + + ticket := specCapture(t, "ticket.md", ticketText, "2026-10-08T10:00:00Z") + + return []sessionEvidence{{ + sessionID: sessionID, + provider: claude.New(), + evidence: evidence, + specs: []spec.Capture{ticket}, + }} +} + +// TestRunTraceExportMatchesPush covers R-002 and R-003: the disk export runs the +// same assembly a push does, so it produces the same materials with the same +// content digests, and every material resolves from its digest. +func TestRunTraceExportMatchesPush(t *testing.T) { + const sessionID = "abc123-session" + ctx := context.Background() + ticketText := "---\nkind: ticket\nuri: https://tracker.example.com/ENG-7\ntitle: Add export\n---\n\nThe export flow is missing.\n" + + store := state.NewGitStore(t.TempDir()) + + // The export writes each material to disk. + outDir := t.TempDir() + sink := newDiskMaterialSink(outDir, false, zerolog.Nop()) + diskAttested, _ := attachSessionEvidence(ctx, sink, store, cleanSession(t, sessionID, ticketText), zerolog.Nop(), false) + require.NoError(t, sink.writeManifest(true)) + require.Equal(t, []string{sessionID}, diskAttested) + + // The same assembly, as a push records it: realDigests makes the fake adder + // hash each material's content exactly as the CAS does. + fake := &fakeMaterialAdder{realDigests: true} + fakeAttested, _ := attachSessionEvidence(ctx, fake, store, cleanSession(t, sessionID, ticketText), zerolog.Nop(), false) + require.Equal(t, []string{sessionID}, fakeAttested) + + // R-002: the same materials, in the same order, under the same digests. + sinkNames := make([]string, 0, len(sink.materials)) + for _, m := range sink.materials { + sinkNames = append(sinkNames, m.Name) + } + require.Equal(t, fake.names(), sinkNames) + for _, m := range sink.materials { + assert.Equal(t, fake.byName(m.Name).digest, m.Digest, "digest of material %q", m.Name) + } + + // R-003: every material is stored under its digest, and its bytes hash back + // to it. + for _, m := range sink.materials { + content, err := os.ReadFile(filepath.Join(outDir, m.File)) + require.NoError(t, err, "material %q is stored", m.Name) + sum := sha256.Sum256(content) + assert.Equal(t, m.Digest, "sha256:"+hex.EncodeToString(sum[:]), "content of %q matches its digest", m.Name) + } + + // R-002: the exported evidence record equals the one the push would upload. + sessionName := evidenceName(sessionID) + sessionDigest := fake.byName(sessionName).digest + exported, err := os.ReadFile(filepath.Join(outDir, materialsDirName, strings.ReplaceAll(sessionDigest, ":", "-"))) + require.NoError(t, err) + assert.JSONEq(t, fake.byName(sessionName).content, string(exported)) + + // The manifest lists the session and resolves every material it names. + var manifest exportManifest + data, err := os.ReadFile(filepath.Join(outDir, manifestFileName)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.True(t, manifest.Redaction) + assert.Contains(t, manifest.Sessions, sessionName) + require.NotEmpty(t, manifest.Materials) + for _, m := range manifest.Materials { + assert.FileExists(t, filepath.Join(outDir, m.File)) + } +} + +// TestRunTraceExportRedaction covers R-004: the export redacts secrets by +// default, and the opt-out keeps them. +func TestRunTraceExportRedaction(t *testing.T) { + const sessionID = "redact1-session" + // Assembled from fragments so that nothing credential-shaped is committed. + const pat = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf" + ticketText := "---\nkind: ticket\nuri: https://tracker.example.com/ENG-9\ntitle: Wire up auth\n---\n\nUse the token " + pat + " for now.\n" + + store := state.NewGitStore(t.TempDir()) + ctx := context.Background() + + containsSecret := func(t *testing.T, dir string) bool { + t.Helper() + found := false + require.NoError(t, filepath.WalkDir(filepath.Join(dir, materialsDirName), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(content), pat) { + found = true + } + + return nil + })) + + return found + } + + t.Run("redaction on by default removes the secret", func(t *testing.T) { + outDir := t.TempDir() + sink := newDiskMaterialSink(outDir, false, zerolog.Nop()) + attested, _ := attachSessionEvidence(ctx, sink, store, cleanSession(t, sessionID, ticketText), zerolog.Nop(), false) + require.Equal(t, []string{sessionID}, attested) + + assert.False(t, containsSecret(t, outDir), "no exported material holds the secret") + }) + + t.Run("opt-out keeps the secret", func(t *testing.T) { + outDir := t.TempDir() + sink := newDiskMaterialSink(outDir, true, zerolog.Nop()) + attested, _ := attachSessionEvidence(ctx, sink, store, cleanSession(t, sessionID, ticketText), zerolog.Nop(), true) + require.Equal(t, []string{sessionID}, attested) + + assert.True(t, containsSecret(t, outDir), "the raw export holds the secret") + }) +} + +// TestRunTraceExportNoSessions covers R-001 in the degenerate case: with no +// recorded sessions the export touches no control plane and writes nothing. +func TestRunTraceExportNoSessions(t *testing.T) { + repoDir := initTempGitRepo(t) + require.NoError(t, state.NewGitStore(filepath.Join(repoDir, ".git")).InitTraceDir()) + t.Chdir(repoDir) + + outDir := filepath.Join(t.TempDir(), "evidence") + got, err := RunTraceExport(context.Background(), zerolog.Nop(), RunTraceExportOpts{OutDir: outDir}) + require.NoError(t, err) + assert.Empty(t, got) + assert.NoDirExists(t, outDir) +} + +// TestRunTraceExportRequiresOutDir rejects an empty output directory. +func TestRunTraceExportRequiresOutDir(t *testing.T) { + _, err := RunTraceExport(context.Background(), zerolog.Nop(), RunTraceExportOpts{}) + require.Error(t, err) +} diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index 7d1ffd869..b47745c4b 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -611,33 +611,13 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts return nil } - sessionCommits := make(map[string][]*state.CommitRecord) - for _, c := range aiCommits { - for _, sid := range c.SessionIDs { - sessionCommits[sid] = append(sessionCommits[sid], c) - } - } - for _, commits := range sessionCommits { - sort.Slice(commits, func(i, j int) bool { - return commits[i].Timestamp < commits[j].Timestamp - }) - } - sessionRecords, err := store.LoadAllSessionRecords() if err != nil { log.Debug().Err(err).Msg("could not load session records") } + sessionCommits := sessionCommitGroups(aiCommits, sessionRecords, opts.AllowEmpty) if len(aiCommits) == 0 { - for sid, rec := range sessionRecords { - // A session that has ended without contributing a commit to this - // branch has nothing new to say; attesting it on every later push - // republishes the same stale evidence. - if !rec.Active { - continue - } - sessionCommits[sid] = nil - } if len(sessionCommits) == 0 { log.Info().Msg("no AI coding sessions recorded, skipping attestation") @@ -698,7 +678,7 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts // Add evidence for each session: its spec materials first, so that the // session material can record their digests, then the session itself. - attestedSessions, attestedSpecs := attachSessionEvidence(ctx, executor, store, sessions, log) + attestedSessions, attestedSpecs := attachSessionEvidence(ctx, executor, store, sessions, log, false) if len(attestedSessions) == 0 { log.Debug().Msg("no evidence successfully added, resetting attestation") @@ -769,6 +749,37 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts return nil } +// sessionCommitGroups groups the AI commits by the sessions that contributed +// to them, each group sorted oldest first. When there are no AI commits and +// allowEmpty is set, it falls back to the active sessions, so their evidence is +// still assembled: the `trace run` and export paths attest a session even when +// it produced no commit. A session that has ended without a commit is left out, +// as attesting it on every later push would republish the same stale evidence. +func sessionCommitGroups(aiCommits []*state.CommitRecord, sessionRecords map[string]*state.SessionRecord, allowEmpty bool) map[string][]*state.CommitRecord { + groups := make(map[string][]*state.CommitRecord) + for _, c := range aiCommits { + for _, sid := range c.SessionIDs { + groups[sid] = append(groups[sid], c) + } + } + for _, commits := range groups { + sort.Slice(commits, func(i, j int) bool { + return commits[i].Timestamp < commits[j].Timestamp + }) + } + + if len(aiCommits) == 0 && allowEmpty { + for sid, rec := range sessionRecords { + if rec == nil || !rec.Active { + continue + } + groups[sid] = nil + } + } + + return groups +} + // sessionEvidence is the evidence of one session before it is added to the // attestation. It is written out only once the attestation exists, because it // records the digests of the spec and skill materials added to it first. @@ -900,7 +911,7 @@ func buildSessionEvidence(ctx context.Context, store *state.Store, repoRoot stri // its spec and skill materials first, so that the session material can record // their digests, then the session itself. It returns the sessions that were // added, and the spec files that each of them stored. -func attachSessionEvidence(ctx context.Context, adder specMaterialAdder, store *state.Store, sessions []sessionEvidence, log zerolog.Logger) (attestedSessions []string, attestedSpecs map[string][]string) { +func attachSessionEvidence(ctx context.Context, adder specMaterialAdder, store *state.Store, sessions []sessionEvidence, log zerolog.Logger, noRedact bool) (attestedSessions []string, attestedSpecs map[string][]string) { attestedSessions = make([]string, 0, len(sessions)) attestedSpecs = make(map[string][]string, len(sessions)) // One allocator for the whole attestation: names taken from the start of @@ -908,7 +919,7 @@ func attachSessionEvidence(ctx context.Context, adder specMaterialAdder, store * // replace an earlier material. names := materials.NewNameAllocator(nil) for _, se := range sessions { - redactor := newSpecRedactor(store.SpecRedactionDir(se.sessionID)) + redactor := newSpecRedactor(store.SpecRedactionDir(se.sessionID), noRedact) entries, warnings, stored, sources := attachSpecs(ctx, adder, redactor, names, se.sessionID, se.specs, log) se.evidence.Data.Warnings = append(se.evidence.Data.Warnings, warnings...) diff --git a/app/cli/pkg/action/trace_pasted_images_test.go b/app/cli/pkg/action/trace_pasted_images_test.go index 1d77972bd..f24690d14 100644 --- a/app/cli/pkg/action/trace_pasted_images_test.go +++ b/app/cli/pkg/action/trace_pasted_images_test.go @@ -126,7 +126,7 @@ func TestAttachSessionEvidencePastedImages(t *testing.T) { provider: claude.New(), evidence: evidence, specs: captures, - }}, zerolog.Nop()) + }}, zerolog.Nop(), false) require.Equal(t, []string{sessionID}, attested) var got aicodingsession.Evidence @@ -233,7 +233,7 @@ func TestPastedImagesNeedAFinder(t *testing.T) { adder := &fakeMaterialAdder{realDigests: true} attachSessionEvidence(context.Background(), adder, state.NewGitStore(t.TempDir()), []sessionEvidence{{ sessionID: "c0ffee-session", provider: cursor.New(), evidence: evidence, - }}, zerolog.Nop()) + }}, zerolog.Nop(), false) assert.Equal(t, []string{"ai-coding-session-c0ffee"}, adder.names()) assert.Contains(t, adder.byName("ai-coding-session-c0ffee").content, strconv.Quote(base64.StdEncoding.EncodeToString(img))) diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index 6797e4c2f..66b8af6a7 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -43,6 +43,23 @@ func (e *SubprocessExitError) Error() string { return fmt.Sprintf("%s exited with status %d", e.Command, e.ExitCode) } +// subprocessError maps the error from running the wrapped command into the +// value TraceRun returns: a SubprocessExitError that carries the exit code so +// the CLI can propagate it, a wrapped error for a failure to launch, or nil on +// success. +func subprocessError(command string, err error) error { + if err == nil { + return nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &SubprocessExitError{Command: command, ExitCode: exitErr.ExitCode()} + } + + return fmt.Errorf("run %s: %w", command, err) +} + // TraceRunOpts configures a single TraceRun invocation. type TraceRunOpts struct { // Store owns the chainloop-trace state directory, parented by the @@ -76,11 +93,20 @@ type TraceRunOpts struct { ContractRequired bool // ActionOpts is the root command's initialized options, used to build - // the attestation executor. Required. + // the attestation executor. Required, except in export mode, which talks + // to no control plane. ActionOpts *ActionsOpts // CLIVersion is the bare CLI version recorded in the attestation // predicate. CLIVersion string + + // ExportDir, when set, runs the session in export mode: once the wrapped + // command exits, the evidence is written to this directory instead of + // being pushed as an attestation. No control plane, no credentials, no + // network. The identity fields above are then ignored. + ExportDir string + // NoRedact disables secret redaction in export mode. Ignored otherwise. + NoRedact bool } // TraceRun wraps a single-shot agent invocation: it cleans any prior @@ -98,20 +124,25 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error return fmt.Errorf("no trace providers selected") } - var authExecOpts []ExecutorOption - if opts.Organization != "" { - authExecOpts = append(authExecOpts, WithForcedOrganization(opts.Organization)) - } - executor, err := NewAttestationExecutor(opts.ActionOpts, opts.CLIVersion, authExecOpts...) - if err != nil { - return err - } - prepErr := prepareTraceRunWorkflow(ctx, log, executor, opts) - if err := executor.Close(); err != nil { - log.Debug().Err(err).Msg("closing auth-check executor") - } - if prepErr != nil { - return prepErr + // Export mode talks to no control plane, so it skips the credential check + // and the up-front workflow creation: the session is recorded and written + // to disk either way. + if opts.ExportDir == "" { + var authExecOpts []ExecutorOption + if opts.Organization != "" { + authExecOpts = append(authExecOpts, WithForcedOrganization(opts.Organization)) + } + executor, err := NewAttestationExecutor(opts.ActionOpts, opts.CLIVersion, authExecOpts...) + if err != nil { + return err + } + prepErr := prepareTraceRunWorkflow(ctx, log, executor, opts) + if err := executor.Close(); err != nil { + log.Debug().Err(err).Msg("closing auth-check executor") + } + if prepErr != nil { + return prepErr + } } // Snapshot the agent settings files before we touch anything else @@ -179,13 +210,33 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error log.Debug().Strs("command", opts.Command).Msg("running wrapped command") - if err := sub.Run(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return &SubprocessExitError{Command: opts.Command[0], ExitCode: exitErr.ExitCode()} + runErr := sub.Run() + + // Export mode writes the evidence whatever the agent's exit status was: the + // point is to inspect what the session produced, and a session the user + // interrupted (a non-zero exit) still has evidence worth reading. The push + // path, in contrast, attests only a clean run. + if opts.ExportDir != "" { + log.Debug().Msg("wrapped command finished; exporting session evidence to disk") + + dir, err := RunTraceExport(ctx, log, RunTraceExportOpts{ + OutDir: opts.ExportDir, + NoRedact: opts.NoRedact, + Mode: aicodingsession.ModeGeneric, + }) + switch { + case err != nil: + // An export failure must not mask the agent's own exit status. + log.Warn().Err(err).Msg("could not export session evidence") + case dir == "": + log.Warn().Msg("no AI coding session was recorded, so nothing was exported") } - return fmt.Errorf("run %s: %w", opts.Command[0], err) + return subprocessError(opts.Command[0], runErr) + } + + if runErr != nil { + return subprocessError(opts.Command[0], runErr) } log.Debug().Msg("wrapped command completed; attesting session") diff --git a/app/cli/pkg/action/trace_run_test.go b/app/cli/pkg/action/trace_run_test.go index 31d2d40fa..acee303ff 100644 --- a/app/cli/pkg/action/trace_run_test.go +++ b/app/cli/pkg/action/trace_run_test.go @@ -16,7 +16,9 @@ package action import ( + "errors" "os" + "os/exec" "path/filepath" "testing" @@ -28,6 +30,29 @@ import ( "github.com/stretchr/testify/require" ) +// TestSubprocessError maps the wrapped command's result the way both the export +// and the push paths rely on: a clean run is no error, a non-zero exit carries +// its code so the CLI can propagate it (and `trace run --export` exports first), +// and a launch failure is wrapped. +func TestSubprocessError(t *testing.T) { + assert.NoError(t, subprocessError("claude", nil)) + + // A real non-zero exit, produced by running a command that fails. + exitErr := exec.Command("sh", "-c", "exit 7").Run() + require.Error(t, exitErr) + got := subprocessError("claude", exitErr) + var sub *SubprocessExitError + require.ErrorAs(t, got, &sub) + assert.Equal(t, 7, sub.ExitCode) + assert.Equal(t, "claude", sub.Command) + + // A failure that is not an exit (e.g. the binary could not be launched) is + // wrapped, not turned into a SubprocessExitError. + launchErr := subprocessError("claude", errors.New("exec: not found")) + require.Error(t, launchErr) + assert.NotErrorAs(t, launchErr, &sub) +} + func TestTraceRunOwnsState(t *testing.T) { t.Run("clean repo", func(t *testing.T) { assert.True(t, traceRunOwnsState(state.NewGitStore(t.TempDir()))) diff --git a/app/cli/pkg/action/trace_skills_scenario_test.go b/app/cli/pkg/action/trace_skills_scenario_test.go index 8ddc867c6..1c519bdf1 100644 --- a/app/cli/pkg/action/trace_skills_scenario_test.go +++ b/app/cli/pkg/action/trace_skills_scenario_test.go @@ -156,7 +156,7 @@ func TestSkillScenario(t *testing.T) { require.Len(t, sessions, 1) adder := &fakeMaterialAdder{realDigests: true} - attested, _ := attachSessionEvidence(ctx, adder, store, sessions, zerolog.Nop()) + attested, _ := attachSessionEvidence(ctx, adder, store, sessions, zerolog.Nop(), false) require.Equal(t, []string{sessionID}, attested) // Each skill gives its definition and its package, then the session diff --git a/app/cli/pkg/action/trace_skills_test.go b/app/cli/pkg/action/trace_skills_test.go index b644306ee..dd03b0048 100644 --- a/app/cli/pkg/action/trace_skills_test.go +++ b/app/cli/pkg/action/trace_skills_test.go @@ -133,7 +133,7 @@ func TestCaptureSkillLoads(t *testing.T) { assert.Equal(t, []string{`skill "asd-ste100" changed after its first use; the evidence holds the skill as it was at the first use`}, warnings) adder := &fakeMaterialAdder{} - _, _ = attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, skills, zerolog.Nop()) + _, _ = attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, skills, zerolog.Nop()) require.Len(t, adder.added, 2) assert.Equal(t, "as it ran", adder.added[0].content) }) @@ -246,7 +246,7 @@ func TestAttachSkills(t *testing.T) { }, trace.SkillUse{Name: steSkillName, FirstUsedAt: "2026-10-07T10:14:51Z", ByModel: 2, ByUser: 1, InSubagents: 1}) adder := &fakeMaterialAdder{} - entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) + entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) assert.Empty(t, warnings) require.Len(t, adder.added, 2) @@ -292,7 +292,7 @@ func TestAttachSkills(t *testing.T) { s := stored(t, map[string]string{skill.DefinitionFile: "# Big", "model.bin": string(big)}, trace.SkillUse{Name: "big", FirstUsedAt: skillTenOClock, ByModel: 1}) adder := &fakeMaterialAdder{} - entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) + entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) assert.Equal(t, []string{`the package of skill "big" is larger than 5 MB and was not uploaded`}, warnings) require.Len(t, adder.added, 1) require.Len(t, entries, 1) @@ -304,7 +304,7 @@ func TestAttachSkills(t *testing.T) { s := stored(t, map[string]string{skill.DefinitionFile: skillDoc}, trace.SkillUse{Name: steSkillName, FirstUsedAt: skillTenOClock, ByModel: 1}) adder := &fakeMaterialAdder{failOn: map[string]bool{"spec-7412a0-skill-asd-ste100-pkg": true}} - entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) + entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) assert.Equal(t, []string{`the package of skill "asd-ste100" was not uploaded`}, warnings) require.Len(t, entries, 1) assert.Empty(t, entries[0].Metadata.PackageDigest) @@ -314,7 +314,7 @@ func TestAttachSkills(t *testing.T) { s := stored(t, map[string]string{skill.DefinitionFile: skillDoc}, trace.SkillUse{Name: steSkillName, FirstUsedAt: skillTenOClock, ByModel: 1}) adder := &fakeMaterialAdder{failOn: map[string]bool{"spec-7412a0-skill-asd-ste100": true}} - entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) + entries, warnings := attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) assert.Empty(t, entries) assert.Equal(t, []string{`skill "asd-ste100" was not recorded`}, warnings) }) @@ -323,7 +323,7 @@ func TestAttachSkills(t *testing.T) { s := stored(t, map[string]string{skill.DefinitionFile: skillDoc}, trace.SkillUse{Name: "superpowers:brainstorming", FirstUsedAt: skillTenOClock, ByModel: 1}) adder := &fakeMaterialAdder{} - _, _ = attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) + _, _ = attachSkills(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), skillSessionID, []sessionSkill{s}, zerolog.Nop()) require.Len(t, adder.added, 2) assert.Equal(t, "spec-7412a0-skill-superpowers-brainstorming", adder.added[0].name) assert.Equal(t, "superpowers-brainstorming.tar.gz", adder.added[1].fileName) diff --git a/app/cli/pkg/action/trace_spec_materials.go b/app/cli/pkg/action/trace_spec_materials.go index 39c3d01aa..ad99703ee 100644 --- a/app/cli/pkg/action/trace_spec_materials.go +++ b/app/cli/pkg/action/trace_spec_materials.go @@ -233,14 +233,19 @@ func addSpecMaterial(ctx context.Context, adder specMaterialAdder, dir, name, se type specRedactor struct { // dir holds one redacted copy per file, named by the SHA-256 of the file // as the agent wrote it. It is dropped when the session ends. - dir string + dir string + // skip returns each file unchanged, cache and scanner both bypassed: the + // opt-out is for a trusted local export, which can then hold secrets + // (R-004). + skip bool redact func(ctx context.Context, doc []byte) ([]byte, error) } // newSpecRedactor returns a redactor that uses the secret scanner of the -// session material and keeps its copies under dir. -func newSpecRedactor(dir string) *specRedactor { - return &specRedactor{dir: dir, redact: func(ctx context.Context, doc []byte) ([]byte, error) { +// session material and keeps its copies under dir. When skip is set the +// redactor returns each file unchanged. +func newSpecRedactor(dir string, skip bool) *specRedactor { + return &specRedactor{dir: dir, skip: skip, redact: func(ctx context.Context, doc []byte) ([]byte, error) { redacted, _, err := aicodingsession.RedactSpecText(ctx, string(doc)) return []byte(redacted), err }} @@ -251,6 +256,12 @@ func newSpecRedactor(dir string) *specRedactor { // header included, so the source address is redacted like the text. A copy // that cannot be stored costs the next push a scan, never this one its spec. func (r *specRedactor) Redact(ctx context.Context, doc []byte) ([]byte, error) { + // The opt-out bypasses the cache too: a cached copy from an earlier + // redacting run would otherwise override it. + if r.skip { + return doc, nil + } + sum := sha256.Sum256(doc) path := filepath.Join(r.dir, hex.EncodeToString(sum[:])) diff --git a/app/cli/pkg/action/trace_spec_materials_test.go b/app/cli/pkg/action/trace_spec_materials_test.go index 54f7e0d5a..f1d4d4d8c 100644 --- a/app/cli/pkg/action/trace_spec_materials_test.go +++ b/app/cli/pkg/action/trace_spec_materials_test.go @@ -124,7 +124,7 @@ func TestAttachSpecs(t *testing.T) { t.Run("each capture becomes an EVIDENCE material and a reference", func(t *testing.T) { adder := &fakeMaterialAdder{} - entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{ticket, plan}, zerolog.Nop()) + entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{ticket, plan}, zerolog.Nop()) assert.Empty(t, warnings) require.Len(t, adder.added, 2) @@ -166,7 +166,7 @@ func TestAttachSpecs(t *testing.T) { local := specCapture(t, "spec-foo.md", "---\nkind: document\nuri: docs/specs/foo.md\n---\n"+fileContent, "2026-10-02T15:30:00Z") local.SourceDigest = pointer.Digest([]byte(fileContent)) - _, warnings, _, sources := attachSpecs(context.Background(), &fakeMaterialAdder{}, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{local, ticket}, zerolog.Nop()) + _, warnings, _, sources := attachSpecs(context.Background(), &fakeMaterialAdder{}, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{local, ticket}, zerolog.Nop()) assert.Empty(t, warnings) assert.Equal(t, pointer.Sources{ @@ -182,7 +182,7 @@ func TestAttachSpecs(t *testing.T) { "---\nkind: ticket\nuri: https://tracker.example.com/issue/1?token="+pat+"\n---\nconfigured with the token "+pat+" and still a 401", "2026-09-16T10:12:03Z") - entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{withSecret}, zerolog.Nop()) + entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{withSecret}, zerolog.Nop()) assert.Empty(t, warnings) require.Len(t, adder.added, 1) @@ -227,7 +227,7 @@ func TestAttachSpecs(t *testing.T) { "---\nkind: ticket\nrole: task\ntitle: \"ENG-1234: Add an export button\"\ndescription: The ticket that the session implements.\n---\nthe ticket", "2026-09-16T10:12:03Z") - entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{described, plan}, zerolog.Nop()) + entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{described, plan}, zerolog.Nop()) assert.Empty(t, warnings) require.Len(t, adder.added, 2) @@ -267,7 +267,7 @@ func TestAttachSpecs(t *testing.T) { MetaRaw: []byte("role: reference\ntitle: PFM-2: [WIP] fix #12\n"), } - entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{hash, colons, image}, zerolog.Nop()) + entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{hash, colons, image}, zerolog.Nop()) assert.Empty(t, warnings) require.Len(t, adder.added, 3) @@ -303,7 +303,7 @@ func TestAttachSpecs(t *testing.T) { "---\nkind: ticket\ntitle: token "+pat+" fails\ndescription: uses "+pat+"\n---\nthe ticket", "2026-09-16T10:12:03Z") - entries, _, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{withSecret}, zerolog.Nop()) + entries, _, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{withSecret}, zerolog.Nop()) require.Len(t, entries, 1) assert.Equal(t, "token [CHAINLOOP_TRACE_REDACTED:github-pat] fails", entries[0].Title) @@ -321,7 +321,7 @@ func TestAttachSpecs(t *testing.T) { MetaRaw: []byte("role: reference\ntitle: mockup for " + pat + "\ndescription: Where the button goes.\n"), } - entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{image}, zerolog.Nop()) + entries, warnings, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{image}, zerolog.Nop()) assert.Empty(t, warnings) require.Len(t, adder.added, 1, "the companion file is no material of its own") @@ -357,7 +357,7 @@ func TestAttachSpecs(t *testing.T) { t.Run("a failed add drops that entry only, and says so", func(t *testing.T) { adder := &fakeMaterialAdder{failOn: map[string]bool{"spec-7412a0-ticket-pfm-7289": true}} - entries, warnings, stored, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{ticket, plan}, zerolog.Nop()) + entries, warnings, stored, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{ticket, plan}, zerolog.Nop()) // A reference to a material that is not in the attestation would point // nowhere, so the entry goes with it. @@ -383,7 +383,7 @@ func TestAttachSpecs(t *testing.T) { c := ticket c.FileName = "design-2.md" - entries, _, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), materials.NewNameAllocator(nil), sessionID, []spec.Capture{a, b, c}, zerolog.Nop()) + entries, _, _, _ := attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), materials.NewNameAllocator(nil), sessionID, []spec.Capture{a, b, c}, zerolog.Nop()) require.Len(t, entries, 3) names := []string{adder.added[0].name, adder.added[1].name, adder.added[2].name} @@ -398,8 +398,8 @@ func TestAttachSpecs(t *testing.T) { first := specCapture(t, "ticket.md", "the first ticket", "2026-09-16T10:12:03Z") second := specCapture(t, "ticket.md", "the second ticket", "2026-09-16T10:12:04Z") - _, _, _, _ = attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), names, "ses_3AbC9x", []spec.Capture{first}, zerolog.Nop()) - _, _, _, _ = attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir()), names, "ses_3AbZ7y", []spec.Capture{second}, zerolog.Nop()) + _, _, _, _ = attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), names, "ses_3AbC9x", []spec.Capture{first}, zerolog.Nop()) + _, _, _, _ = attachSpecs(context.Background(), adder, newSpecRedactor(t.TempDir(), false), names, "ses_3AbZ7y", []spec.Capture{second}, zerolog.Nop()) require.Len(t, adder.added, 2) assert.NotEqual(t, adder.added[0].name, adder.added[1].name, "one attestation must never hold two materials under one name") @@ -480,7 +480,7 @@ func TestSpecRedactor(t *testing.T) { }) t.Run("the default scanner removes secrets", func(t *testing.T) { - got, err := newSpecRedactor(t.TempDir()).Redact(context.Background(), []byte("the token "+pat+" fails")) + got, err := newSpecRedactor(t.TempDir(), false).Redact(context.Background(), []byte("the token "+pat+" fails")) require.NoError(t, err) assert.Equal(t, "the token [CHAINLOOP_TRACE_REDACTED:github-pat] fails", string(got)) }) diff --git a/app/cli/pkg/action/trace_spec_pointers_test.go b/app/cli/pkg/action/trace_spec_pointers_test.go index ab04557eb..d4108564d 100644 --- a/app/cli/pkg/action/trace_spec_pointers_test.go +++ b/app/cli/pkg/action/trace_spec_pointers_test.go @@ -82,7 +82,7 @@ func TestAttachSessionEvidenceSpecPointers(t *testing.T) { provider: claude.New(), evidence: evidence, specs: []spec.Capture{ticket, screenshot}, - }}, zerolog.Nop()) + }}, zerolog.Nop(), false) require.Equal(t, []string{sessionID}, attested) ticketDigest := adder.byName("spec-7c1e2d-ticket-eng-1234").digest @@ -187,7 +187,7 @@ func TestAttachSessionEvidenceLocalSourcePointers(t *testing.T) { provider: claude.New(), evidence: evidence, specs: captures, - }}, zerolog.Nop()) + }}, zerolog.Nop(), false) require.Equal(t, []string{sessionID}, attested) material := adder.byName("spec-9a8b7c-design-note")