diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go
index 6067f6e75..4e2b18ea1 100644
--- a/app/cli/cmd/root.go
+++ b/app/cli/cmd/root.go
@@ -62,7 +62,12 @@ const (
confirmWhenUserToken = "confirmWhenUserToken"
// Skip ActionOpts initialization for commands that operate locally
skipActionOptsInit = "skipActionOptsInit"
- appName = "chainloop"
+ // reportTelemetryAnyway lets a command annotated skipActionOptsInit opt back
+ // into telemetry at runtime, once it knows it ran against the control plane.
+ // `trace run` is skipActionOptsInit so a local-mode run opens no connection,
+ // but a control-plane run of it must still be reported as it was before.
+ reportTelemetryAnyway = "reportTelemetryAnyway"
+ appName = "chainloop"
//nolint:gosec
tokenEnvVarName = "CHAINLOOP_TOKEN"
// Follow the convention stated on https://consoledonottrack.com/
@@ -147,6 +152,101 @@ func handleOrganizationExecutionError(executed *cobra.Command, err error) error
return err
}
+// ensureActionOpts builds the control-plane connection and the shared
+// ActionOpts from the resolved configuration: the auth token, an optional
+// federated token, the gRPC options, the current-organization lookup, and the
+// interactive-token warning. It is the control-plane half of the root
+// PersistentPreRunE, factored out so a command annotated skipActionOptsInit can
+// skip it entirely and, when it does need the control plane, call this lazily
+// instead. A trace command in LOCAL storage mode never calls it, so it makes no
+// control-plane request (R-005). It sets the package-level ActionOpts.
+func ensureActionOpts(cmd *cobra.Command) error {
+ if apiInsecure() {
+ logger.Debug().Msg("API contacted in insecure mode")
+ }
+
+ authToken, isUserToken, err := loadAuthToken(cmd)
+ if err != nil {
+ return err
+ }
+ authTokenIsUser = isUserToken
+
+ // If the auth token is not set and the command supports federated auth, we try to discover the runner and use the federated token for the runner if available
+ if authToken == "" && cmdSupportsFederatedAuth(cmd) {
+ r := crafter.DiscoverRunner(authToken, logger)
+ if r.IsAuthenticated() && r.FederatedToken() != "" {
+ logger.Debug().Str("runner", r.ID().String()).Msg("using federated auth token")
+ authToken = r.FederatedToken()
+ // reset isUserToken to false because we are using a federated token and we don't want to ask for confirmation
+ isUserToken = false
+ }
+ }
+
+ var opts = []grpcconn.Option{
+ grpcconn.WithInsecure(apiInsecure()),
+ grpcconn.WithCLIVersion(fullVersion()),
+ }
+
+ if maxRecv := apiMaxRecvMsgSize(); maxRecv > 0 {
+ opts = append(opts, grpcconn.WithMaxRecvMsgSize(maxRecv))
+ }
+
+ if caValue := viper.GetString(confOptions.controlplaneCA.viperKey); caValue != "" {
+ // Check if the value is a file path, if it is we read the content and encode it to base64, if not we assume it's the content already
+ if _, err := os.Stat(caValue); err == nil {
+ opts = append(opts, grpcconn.WithCAFile(caValue))
+ } else {
+ opts = append(opts, grpcconn.WithCAContent(caValue))
+ }
+ }
+
+ controlplaneURL := viper.GetString(confOptions.controlplaneAPI.viperKey)
+
+ // If no organization is selected, load it from the server and save it.
+ orgName := effectiveOrganization(cmd)
+ if orgName == "" {
+ conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
+ if err != nil {
+ return err
+ }
+
+ currentContext, err := action.NewConfigCurrentContext(newActionOpts(logger, conn, authToken)).Run()
+ if err == nil && currentContext.CurrentMembership != nil {
+ if err := setLocalOrganization(currentContext.CurrentMembership.Org.Name); err != nil {
+ return fmt.Errorf("writing config file: %w", err)
+ }
+ }
+ }
+
+ // Reload the connection now that we have the organization name.
+ orgName = effectiveOrganization(cmd)
+ if orgName != "" {
+ opts = append(opts, grpcconn.WithOrgName(orgName))
+ }
+
+ // Warn users when the session is interactive, and the operation is supposed to use an API token instead
+ if shouldAskForConfirmation(cmd) && authToken != "" && isUserToken && !flagYes {
+ logger.Warn().Msg("User-attended mode detected. This is intended for local testing only. For CI/CD or automated workflows, please use an API token.")
+ if !confirmationPrompt(fmt.Sprintf("This command will run against the organization %q", orgName)) {
+ return errors.New("command canceled by user")
+ }
+ }
+
+ conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
+ if err != nil {
+ return err
+ }
+
+ ActionOpts = newActionOpts(logger, conn, authToken)
+
+ // Add custom builtins to rego engine
+ if err = builtins.RegisterDiscoverBuiltin(conn); err != nil {
+ return fmt.Errorf("failed to register discover builtin: %w", err)
+ }
+
+ return nil
+}
+
func NewRootCmd(l zerolog.Logger) *cobra.Command {
// Seed the root logger here and not only in PersistentPreRunE: cobra
// rejects unknown subcommands and invalid flags before that hook runs, and
@@ -175,95 +275,15 @@ Command reference: ` + cliReferenceURL,
logger.Debug().Str("path", viper.ConfigFileUsed()).Msg("using config file")
// Commands annotated with skipActionOptsInit don't need ActionOpts initialization
- // These are local-only commands that don't interact with the control plane
+ // These are local-only commands that don't interact with the control plane.
+ // The trace pre-push hook and `trace init` carry the annotation and build
+ // ActionOpts lazily with ensureActionOpts, only in their control-plane
+ // branch, so a LOCAL-mode repository makes no control-plane call at all.
if cmd.Annotations[skipActionOptsInit] == trueString {
return nil
}
- if apiInsecure() {
- logger.Debug().Msg("API contacted in insecure mode")
- }
-
- authToken, isUserToken, err := loadAuthToken(cmd)
- if err != nil {
- return err
- }
- authTokenIsUser = isUserToken
-
- // If the auth token is not set and the command supports federated auth, we try to discover the runner and use the federated token for the runner if available
- if authToken == "" && cmdSupportsFederatedAuth(cmd) {
- r := crafter.DiscoverRunner(authToken, logger)
- if r.IsAuthenticated() && r.FederatedToken() != "" {
- logger.Debug().Str("runner", r.ID().String()).Msg("using federated auth token")
- authToken = r.FederatedToken()
- // reset isUserToken to false because we are using a federated token and we don't want to ask for confirmation
- isUserToken = false
- }
- }
-
- var opts = []grpcconn.Option{
- grpcconn.WithInsecure(apiInsecure()),
- grpcconn.WithCLIVersion(fullVersion()),
- }
-
- if maxRecv := apiMaxRecvMsgSize(); maxRecv > 0 {
- opts = append(opts, grpcconn.WithMaxRecvMsgSize(maxRecv))
- }
-
- if caValue := viper.GetString(confOptions.controlplaneCA.viperKey); caValue != "" {
- // Check if the value is a file path, if it is we read the content and encode it to base64, if not we assume it's the content already
- if _, err := os.Stat(caValue); err == nil {
- opts = append(opts, grpcconn.WithCAFile(caValue))
- } else {
- opts = append(opts, grpcconn.WithCAContent(caValue))
- }
- }
-
- controlplaneURL := viper.GetString(confOptions.controlplaneAPI.viperKey)
-
- // If no organization is selected, load it from the server and save it.
- orgName := effectiveOrganization(cmd)
- if orgName == "" {
- conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
- if err != nil {
- return err
- }
-
- currentContext, err := action.NewConfigCurrentContext(newActionOpts(logger, conn, authToken)).Run()
- if err == nil && currentContext.CurrentMembership != nil {
- if err := setLocalOrganization(currentContext.CurrentMembership.Org.Name); err != nil {
- return fmt.Errorf("writing config file: %w", err)
- }
- }
- }
-
- // Reload the connection now that we have the organization name.
- orgName = effectiveOrganization(cmd)
- if orgName != "" {
- opts = append(opts, grpcconn.WithOrgName(orgName))
- }
-
- // Warn users when the session is interactive, and the operation is supposed to use an API token instead
- if shouldAskForConfirmation(cmd) && authToken != "" && isUserToken && !flagYes {
- logger.Warn().Msg("User-attended mode detected. This is intended for local testing only. For CI/CD or automated workflows, please use an API token.")
- if !confirmationPrompt(fmt.Sprintf("This command will run against the organization %q", orgName)) {
- return errors.New("command canceled by user")
- }
- }
-
- conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
- if err != nil {
- return err
- }
-
- ActionOpts = newActionOpts(logger, conn, authToken)
-
- // Add custom builtins to rego engine
- if err = builtins.RegisterDiscoverBuiltin(conn); err != nil {
- return fmt.Errorf("failed to register discover builtin: %w", err)
- }
-
- return nil
+ return ensureActionOpts(cmd)
},
PersistentPostRunE: func(_ *cobra.Command, _ []string) error {
if ActionOpts != nil {
diff --git a/app/cli/cmd/telemetry_report.go b/app/cli/cmd/telemetry_report.go
index ae5cd65df..9b8d52cd9 100644
--- a/app/cli/cmd/telemetry_report.go
+++ b/app/cli/cmd/telemetry_report.go
@@ -119,12 +119,19 @@ func telemetryIdentity() *token.ParsedToken {
// totals with something the user never ran.
//
// Beyond that, commands annotated skipActionOptsInit are the local-only ones, such as the
-// git hooks, which have never been reported.
+// git hooks, which have never been reported. A command can opt back in at runtime with
+// reportTelemetryAnyway once it knows it ran against the control plane: `trace run` is
+// skipActionOptsInit so a local-mode run opens no connection, yet a control-plane run of
+// it must still be reported as it was before local mode existed.
func shouldReportCommand(executed *cobra.Command) bool {
if executed == nil || isTelemetryCommand(executed) || isCompletionCommand(executed) {
return false
}
+ if executed.Annotations[reportTelemetryAnyway] == trueString {
+ return true
+ }
+
return executed.Annotations[skipActionOptsInit] != trueString
}
diff --git a/app/cli/cmd/telemetry_report_test.go b/app/cli/cmd/telemetry_report_test.go
index 7a030deec..56d241b9f 100644
--- a/app/cli/cmd/telemetry_report_test.go
+++ b/app/cli/cmd/telemetry_report_test.go
@@ -282,6 +282,13 @@ func TestShouldReportCommand(t *testing.T) {
name: "local-only command is not reported",
annotations: map[string]string{skipActionOptsInit: trueString},
},
+ {
+ // A control-plane `trace run` is skipActionOptsInit for lazy
+ // connection setup but opts back into reporting at runtime.
+ name: "skipActionOptsInit with reportTelemetryAnyway is reported",
+ annotations: map[string]string{skipActionOptsInit: trueString, reportTelemetryAnyway: trueString},
+ want: true,
+ },
}
for _, tc := range testCases {
diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go
index ed1ec5f1a..f4974cad7 100644
--- a/app/cli/cmd/trace_hook.go
+++ b/app/cli/cmd/trace_hook.go
@@ -112,7 +112,7 @@ func applyPrePushPolicy(executed *cobra.Command, err error) error {
return err
}
- return action.PrePushFailure(err, config.LoadRequireTraceFromYML("."), logger)
+ return action.PrePushFailure(err, config.LoadTraceRequiredFromYML("."), logger)
}
func newTraceHookGitPrePushCmd() *cobra.Command {
@@ -121,12 +121,35 @@ func newTraceHookGitPrePushCmd() *cobra.Command {
Short: "Handle the pre-push git hook",
// Any error of this command, setup included, goes through
// action.PrePushFailure in Execute.
+ //
+ // skipActionOptsInit keeps root from building the control-plane
+ // connection (and probing the current org) up front: a LOCAL-mode
+ // repository must reach no control plane, so the connection is built
+ // lazily below, only when the storage mode is CONTROL_PLANE.
Annotations: map[string]string{
prePushHookAnnotation: trueString,
+ skipActionOptsInit: trueString,
},
RunE: func(cmd *cobra.Command, _ []string) error {
InitHookLogger()
+ // The hook runs at the repository root, so "." resolves the nearest
+ // .chainloop.yml the same way the push identity does.
+ mode, err := config.LoadTraceStorageFromYML(".")
+ if err != nil {
+ return err
+ }
+
+ if mode == config.TraceStorageLocal {
+ return action.HandlePrePushHookLocal(cmd.Context(), logger)
+ }
+
+ // Control plane: build ActionOpts lazily, since this command is
+ // skipActionOptsInit and root did not.
+ if err := ensureActionOpts(cmd); err != nil {
+ return err
+ }
+
return action.HandlePrePushHook(cmd.Context(), logger, action.RunTracePushOpts{
ActionOpts: ActionOpts,
CLIVersion: Version,
diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go
index 8cecd15a6..eae02fa1c 100644
--- a/app/cli/cmd/trace_init.go
+++ b/app/cli/cmd/trace_init.go
@@ -40,16 +40,23 @@ import (
// newTraceInitCmd creates the trace init subcommand.
func newTraceInitCmd() *cobra.Command {
var (
- project string
- contract string
- claudeFlag bool
- cursorFlag bool
- opencodeFlag bool
+ project string
+ contract string
+ localFlag bool
+ tracePathFlag string
+ claudeFlag bool
+ cursorFlag bool
+ opencodeFlag bool
)
cmd := &cobra.Command{
- Use: "init",
- Short: "Initialize git hooks for automatic AI session tracing",
+ Use: "init", //nolint:goconst // cobra verb, kept literal like the other init subcommands
+ // skipActionOptsInit keeps root from building the control-plane
+ // connection up front. Control-plane mode builds it lazily with
+ // ensureActionOpts below; local mode never does, so it reaches no
+ // control plane at all (R-008).
+ Annotations: map[string]string{skipActionOptsInit: trueString},
+ Short: "Initialize git hooks for automatic AI session tracing",
Long: `Initialize git hooks that automatically trace AI coding sessions and
create Chainloop attestations when you push.
@@ -71,26 +78,77 @@ 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 for local mode: it installs the same
+hooks but creates no workflow, needs no login, and reaches no control plane. It
+writes traceStorage: LOCAL to .chainloop.yml (and tracePath when --trace-path is
+given), so every clone records sessions on disk. The pre-push hook then stores a
+session's evidence under the path instead of pushing an attestation. --contract,
+--workflow, --org and --project are control-plane settings and cannot be used
+with --local; --require-trace can.`,
RunE: func(cmd *cobra.Command, _ []string) error {
gitDir, repoRoot, err := tracegit.FindGitDirAndRoot()
if err != nil {
return err
}
+ // A tracePath only has meaning in local mode, so naming one turns it
+ // on rather than being silently ignored.
+ if tracePathFlag != "" {
+ localFlag = true
+ }
+
+ // Local mode contacts no control plane, so the control-plane
+ // identity and workflow flags are contradictions rather than extra
+ // configuration. --require-trace is orthogonal and allowed.
+ if localFlag {
+ for _, name := range []string{"contract", "workflow", "org", "project"} { //nolint:goconst // flag names, kept literal like the other flag references
+ if cmd.Flags().Changed(name) {
+ return fmt.Errorf("--%s cannot be used with --local: local mode contacts no control plane and creates no workflow", name)
+ }
+ }
+ }
+
cfg, err := resolveTraceInitConfig(cmd, repoRoot, project)
if err != nil {
return err
}
+ cfg.local = localFlag
+ cfg.tracePath = tracePathFlag
+ cfg.saveTracePath = localFlag && tracePathFlag != ""
+
+ // A control-plane init over a repository previously set to LOCAL has
+ // to clear the stale policy, or the pre-push hook keeps storing on
+ // disk while init configured the control plane, and the two
+ // disagree. A fresh control-plane repository writes nothing and keeps
+ // relying on the CONTROL_PLANE default.
+ existingMode, err := config.LoadTraceStorageFromYML(repoRoot)
+ if err != nil && !localFlag {
+ // An invalid traceStorage already in .chainloop.yml would make
+ // every pre-push fail. A control-plane init cannot pick a mode
+ // for a value it cannot read, so surface it now rather than
+ // install hooks over a config that only errors. An explicit
+ // --local run overrides the value and is let through.
+ return fmt.Errorf("existing .chainloop.yml: %w", err)
+ }
+ cfg.traceStorage, cfg.saveTraceStorage = traceStorageToPersist(localFlag, existingMode)
+
+ // Control-plane mode fills in the identity and opens an executor,
+ // building the connection lazily (the command is skipActionOptsInit).
+ // Local mode resolves no identity and opens no executor.
+ var executor *action.AttestationExecutor
+ if !localFlag {
+ if err := ensureActionOpts(cmd); err != nil {
+ return err
+ }
- // 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)
+ 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 +165,11 @@ 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.
+ 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.
@@ -189,13 +250,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 cfg.local {
+ writeTraceInitLocalSummary(os.Stdout, repoRoot, 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,8 +273,10 @@ 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 local mode: no control plane, no login, no workflow; the pre-push hook stores session evidence on disk")
+ cmd.Flags().StringVar(&tracePathFlag, "trace-path", "", "where local-mode sessions are stored (default: under .git/chainloop-trace/local-sessions); implies --local")
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().Bool("require-trace", false, "block pushes when a required trace is missing")
cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)")
cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks")
cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks")
@@ -278,6 +348,63 @@ Learn more: %s
`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL)
}
+// 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, repoRoot string, harnesses []string) {
+ fmt.Fprint(w, "\nCongratulations, your repository is initialized in local mode\n\n")
+ fmt.Fprint(w, " storage local (no control plane)\n")
+
+ if path := config.LoadTracePathFromYML(repoRoot); path != "" {
+ fmt.Fprintf(w, " sessions stored under %s on push\n", path)
+ } else {
+ fmt.Fprint(w, " sessions stored under .git/chainloop-trace/local-sessions on push\n")
+ }
+
+ fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", "))
+}
+
+// writeTraceLocalNextSteps says what is left after a local init. Committing
+// .chainloop.yml shares the choice: traceStorage: LOCAL is a repository policy a
+// clone inherits. The git hooks that record a session live under .git and are
+// not cloned, though, so each teammate installs their own by running chainloop
+// trace init --local once after cloning. Unlike the control-plane path there is
+// nothing to push to a backend; the evidence lands on disk when the session's
+// commits are pushed.
+func writeTraceLocalNextSteps(w io.Writer, repoRoot, workDir string, installed []trace.Provider) {
+ files := make([]string, 0, len(installed)+1)
+ add := func(absolute string) {
+ if rel, err := filepath.Rel(workDir, absolute); err == nil {
+ files = append(files, rel)
+ return
+ }
+
+ files = append(files, absolute)
+ }
+
+ add(filepath.Join(repoRoot, config.ChainloopYMLName(repoRoot)))
+
+ for _, p := range installed {
+ add(p.SettingsFile(repoRoot))
+ }
+
+ fmt.Fprintf(w, `
+What's next
+
+ 1. Commit these files so the local-mode policy is shared:
+ git add %s
+ 2. Start %s and write some code
+ 3. Commit and push as usual. The session evidence is written to disk, with no
+ control plane; the push tells you where
+
+Each teammate installs their own git hooks once after cloning, since the hooks
+live under .git and are not cloned:
+ chainloop trace init --local
+
+Learn more: %s
+`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL)
+}
+
// providerNames names the harnesses, in the order they were installed.
func providerNames(installed []trace.Provider) []string {
names := make([]string, 0, len(installed))
@@ -464,9 +591,37 @@ type traceInitConfig struct {
organization string
requireTrace bool
+ // local records that this is a local-mode init: it writes traceStorage:
+ // LOCAL and reaches no control plane. tracePath is the optional override for
+ // where local sessions are stored.
+ local bool
+ tracePath string
+ // traceStorage is the value written when saveTraceStorage is set. Local init
+ // writes LOCAL. A control-plane init writes CONTROL_PLANE only to clear a
+ // stale LOCAL left by an earlier local init.
+ traceStorage string
+
// The save* fields mark the values the user set on this run. Only those
// are written back to .chainloop.yml, leaving the rest of the file alone.
saveProject, saveWorkflow, saveOrganization, saveRequireTrace bool
+ saveTraceStorage, saveTracePath bool
+}
+
+// traceStorageToPersist decides which traceStorage value trace init writes and
+// whether it writes one at all. Local init always records LOCAL. A control-plane
+// init writes CONTROL_PLANE only to clear a stale LOCAL an earlier local init
+// left behind, so the pre-push hook never keeps storing on disk after a
+// control-plane re-init. A fresh control-plane repository writes nothing and
+// keeps its config minimal, relying on the CONTROL_PLANE default.
+func traceStorageToPersist(localFlag bool, existingMode string) (value string, save bool) {
+ switch {
+ case localFlag:
+ return config.TraceStorageLocal, true
+ case existingMode == config.TraceStorageLocal:
+ return config.TraceStorageControlPlane, true
+ default:
+ return "", false
+ }
}
// resolveTraceInitConfig reads the trace settings from the flags, falling back
@@ -524,6 +679,22 @@ func resolveTraceInitConfig(cmd *cobra.Command, repoRoot, projectFlag string) (*
// save persists the values the user passed to .chainloop.yml so the trace
// hooks target the same organization, project and workflow on every push.
func (c *traceInitConfig) save(repoRoot string) error {
+ // Storage mode first: in local mode it is the field that makes the push
+ // hook store on disk, so it must land even if nothing else is written.
+ if c.saveTraceStorage {
+ if err := config.SaveTraceStorageToYML(repoRoot, c.traceStorage); err != nil {
+ return err
+ }
+ logger.Debug().Str("traceStorage", c.traceStorage).Msg("traceStorage saved to .chainloop.yml")
+ }
+
+ if c.saveTracePath {
+ if err := config.SaveTracePathToYML(repoRoot, c.tracePath); err != nil {
+ return err
+ }
+ logger.Debug().Str("tracePath", c.tracePath).Msg("tracePath saved to .chainloop.yml")
+ }
+
if c.saveProject {
if err := config.SaveProjectToYML(repoRoot, c.project); err != nil {
return err
@@ -546,9 +717,12 @@ func (c *traceInitConfig) save(repoRoot string) error {
}
if c.saveRequireTrace {
- if err := config.SaveRequireTraceToYML(repoRoot, c.requireTrace); err != nil {
+ // Written as the current traceRequired field; the deprecated
+ // requireTrace is only read back for an older config.
+ if err := config.SaveTraceRequiredToYML(repoRoot, c.requireTrace); err != nil {
return err
}
+ logger.Debug().Bool("traceRequired", c.requireTrace).Msg("traceRequired saved to .chainloop.yml")
}
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..067ae5d70
--- /dev/null
+++ b/app/cli/cmd/trace_init_local_test.go
@@ -0,0 +1,226 @@
+//
+// 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 (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config"
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func gitInit(t *testing.T, dir string) {
+ t.Helper()
+ cmd := exec.Command("git", "init", dir)
+ require.NoError(t, cmd.Run())
+}
+
+// runTraceInit executes `trace init` with the given args in dir, with no control
+// plane reachable. It returns the RunE error.
+func runTraceInit(t *testing.T, dir string, args ...string) error {
+ t.Helper()
+ t.Chdir(dir)
+
+ cmd := newTraceInitCmd()
+ cmd.Flags().String("org", "", "organization name")
+ cmd.SetArgs(args)
+
+ return cmd.Execute()
+}
+
+// TestTraceInitLocal covers R-008/R-011: `trace init --local` sets the
+// repository up for local storage with no control plane, writing traceStorage:
+// LOCAL and no control-plane identity.
+func TestTraceInitLocal(t *testing.T) {
+ t.Run("writes LOCAL storage and no identity, installs hooks", func(t *testing.T) {
+ repoDir := t.TempDir()
+ gitInit(t, repoDir)
+
+ require.NoError(t, runTraceInit(t, repoDir, "--local", "--claude"))
+
+ data, err := os.ReadFile(filepath.Join(repoDir, chainloopYMLName))
+ require.NoError(t, err)
+ got := string(data)
+ assert.Contains(t, got, "traceStorage: LOCAL")
+ assert.NotContains(t, got, "organization:")
+ assert.NotContains(t, got, "workflowName:")
+ assert.NotContains(t, got, "projectName:")
+
+ // The harness hooks were installed, so a session gets recorded.
+ _, err = os.Stat(filepath.Join(repoDir, claudeSettings))
+ assert.NoError(t, err)
+ })
+
+ t.Run("--trace-path implies local and is written", func(t *testing.T) {
+ repoDir := t.TempDir()
+ gitInit(t, repoDir)
+
+ require.NoError(t, runTraceInit(t, repoDir, "--trace-path", "evidence/sessions", "--claude"))
+
+ data, err := os.ReadFile(filepath.Join(repoDir, chainloopYMLName))
+ require.NoError(t, err)
+ got := string(data)
+ assert.Contains(t, got, "traceStorage: LOCAL")
+ assert.Contains(t, got, "tracePath: evidence/sessions")
+ })
+
+ t.Run("--require-trace is allowed and written as traceRequired", func(t *testing.T) {
+ repoDir := t.TempDir()
+ gitInit(t, repoDir)
+
+ require.NoError(t, runTraceInit(t, repoDir, "--local", "--require-trace", "--claude"))
+
+ data, err := os.ReadFile(filepath.Join(repoDir, chainloopYMLName))
+ require.NoError(t, err)
+ got := string(data)
+ assert.Contains(t, got, "traceStorage: LOCAL")
+ assert.Contains(t, got, "traceRequired: true")
+ })
+
+ t.Run("control-plane flags are rejected with --local", func(t *testing.T) {
+ for _, flag := range [][]string{
+ {"--contract", "my-contract"},
+ {"--workflow", "my-workflow"},
+ {"--project", "my-project"}, //nolint:goconst // test fixture value
+ } {
+ repoDir := t.TempDir()
+ gitInit(t, repoDir)
+ args := append([]string{"--local", "--claude"}, flag...)
+ err := runTraceInit(t, repoDir, args...)
+ assert.ErrorContains(t, err, "cannot be used with --local", "flag %v", flag)
+ }
+ })
+}
+
+// TestTraceInitInvalidStorage covers the guard that a control-plane init over an
+// invalid traceStorage fails before installing hooks, so init never reports
+// success for a repository whose every pre-push would then error, while an
+// explicit --local run overrides the bad value.
+func TestTraceInitInvalidStorage(t *testing.T) {
+ t.Run("control-plane init over an invalid traceStorage errors before install", func(t *testing.T) {
+ repoDir := t.TempDir()
+ gitInit(t, repoDir)
+ require.NoError(t, os.WriteFile(filepath.Join(repoDir, chainloopYMLName),
+ []byte("traceStorage: bogus\n"), 0600))
+
+ err := runTraceInit(t, repoDir, "--claude")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "traceStorage")
+
+ // Nothing was written: the harness settings were not installed.
+ _, statErr := os.Stat(filepath.Join(repoDir, claudeSettings))
+ assert.True(t, os.IsNotExist(statErr), "hooks must not be installed over a broken config")
+ })
+
+ t.Run("--local overrides an invalid traceStorage", func(t *testing.T) {
+ repoDir := t.TempDir()
+ gitInit(t, repoDir)
+ require.NoError(t, os.WriteFile(filepath.Join(repoDir, chainloopYMLName),
+ []byte("traceStorage: bogus\n"), 0600))
+
+ require.NoError(t, runTraceInit(t, repoDir, "--local", "--claude"))
+
+ data, err := os.ReadFile(filepath.Join(repoDir, chainloopYMLName))
+ require.NoError(t, err)
+ assert.Contains(t, string(data), "traceStorage: LOCAL")
+ })
+}
+
+func TestWriteTraceInitLocalSummary(t *testing.T) {
+ out := &bytes.Buffer{}
+ writeTraceInitLocalSummary(out, t.TempDir(), []string{providerClaudeCode, "cursor"}) //nolint:goconst // provider wire name, kept literal like the other provider tests
+
+ got := out.String()
+ assert.Contains(t, got, "local mode")
+ assert.Contains(t, got, "no control plane")
+ assert.Contains(t, got, "claude-code, cursor")
+ assert.NotContains(t, got, "organization")
+}
+
+func TestWriteTraceLocalNextSteps(t *testing.T) {
+ repoRoot := t.TempDir()
+ out := &bytes.Buffer{}
+ writeTraceLocalNextSteps(out, repoRoot, repoRoot, providers.ByNames([]string{providerClaudeCode}))
+
+ got := out.String()
+ assert.Contains(t, got, chainloopYMLName)
+ assert.Contains(t, got, claudeSettings)
+ assert.Contains(t, got, "written to disk")
+ assert.Contains(t, got, traceDocsURL)
+ // The git hooks live under .git and are not cloned, so a teammate must run
+ // init themselves; the steps must say so rather than imply a commit is
+ // enough.
+ assert.Contains(t, got, "chainloop trace init --local")
+ assert.Contains(t, got, "not cloned")
+}
+
+func TestTraceStorageToPersist(t *testing.T) {
+ testCases := []struct {
+ name string
+ local bool
+ existingMode string
+ wantValue string
+ wantSave bool
+ }{
+ {
+ name: "local init writes LOCAL",
+ local: true,
+ wantValue: config.TraceStorageLocal,
+ wantSave: true,
+ },
+ {
+ name: "local init writes LOCAL even over an existing control plane",
+ local: true,
+ existingMode: config.TraceStorageControlPlane,
+ wantValue: config.TraceStorageLocal,
+ wantSave: true,
+ },
+ {
+ // A control-plane re-init must clear a stale LOCAL so the pre-push
+ // hook stops storing on disk.
+ name: "control-plane init over LOCAL clears it to CONTROL_PLANE",
+ local: false,
+ existingMode: config.TraceStorageLocal,
+ wantValue: config.TraceStorageControlPlane,
+ wantSave: true,
+ },
+ {
+ name: "fresh control-plane init writes nothing",
+ local: false,
+ existingMode: config.TraceStorageControlPlane,
+ wantSave: false,
+ },
+ {
+ name: "control-plane init with no existing mode writes nothing",
+ local: false,
+ wantSave: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ value, save := traceStorageToPersist(tc.local, tc.existingMode)
+ assert.Equal(t, tc.wantSave, save)
+ assert.Equal(t, tc.wantValue, value)
+ })
+ }
+}
diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go
index 998351966..ef349ecfb 100644
--- a/app/cli/cmd/trace_run.go
+++ b/app/cli/cmd/trace_run.go
@@ -31,11 +31,15 @@ const traceRunLong = `Wrap an external command (typically an AI coding agent) an
Chainloop attestation for the session once the command exits successfully.
Sessions without git commits are still attested.
-trace run is isolated: it ignores .chainloop.yml entirely. The attestation
-identity must come from the --org, --project, --workflow flags (mandatory)
-and the optional --version flag. It does not read from or write to
-.chainloop.yml, and removes every hook and local trace artifact when the
-wrapped command exits, so a session never leaks setup into the next one.
+trace run keeps its identity isolated from .chainloop.yml: the attestation
+--org, --project and --workflow come from the flags (mandatory) and the
+optional --version flag, never from the file. It does read the repository's
+traceStorage mode, though: in a local-mode repository (traceStorage: LOCAL) the
+session is written to disk when the command exits, with no control plane, and
+the --org, --project and --workflow flags are not required. Either way it
+removes the temporary hooks and trace state when the wrapped command exits while
+preserving stored local session evidence, 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
@@ -58,6 +62,10 @@ func newTraceRunCmd() *cobra.Command {
Short: "Run a single-shot AI coding session and attest it",
Long: traceRunLong,
Args: cobra.MinimumNArgs(1),
+ // skipActionOptsInit keeps root from building the control-plane
+ // connection up front; the attestation path builds it lazily with
+ // ensureActionOpts, and a local-mode run never does (R-005).
+ Annotations: map[string]string{skipActionOptsInit: trueString},
RunE: func(cmd *cobra.Command, args []string) error {
// Outside a git repository trace state lives in a deterministic
// out-of-tree directory keyed by the working directory. The store
@@ -88,28 +96,56 @@ func newTraceRunCmd() *cobra.Command {
store = state.NewGitStore(gitDir)
}
- // --org is a persistent flag declared on the root command,
- // so we cannot MarkFlagRequired it on this subcommand.
- organization, err := cmd.Flags().GetString("org")
+ // The storage mode is a repository policy, read even though trace
+ // run keeps its identity flag-only: a LOCAL repository stores to
+ // disk and needs no identity and no control plane.
+ storageMode, err := config.LoadTraceStorageFromYML(repoRoot)
if err != nil {
- return fmt.Errorf("reading --org flag: %w", err)
+ return err
}
+ local := storageMode == config.TraceStorageLocal
+
+ var (
+ organization string
+ contractName string
+ contractRequired bool
+ )
+
+ if !local {
+ // A control-plane run is reported in telemetry as it was before
+ // local mode made this command skipActionOptsInit. A local run
+ // is not: it leaves the annotation unset and stays excluded.
+ cmd.Annotations[reportTelemetryAnyway] = trueString
+
+ // --org is a persistent flag declared on the root command,
+ // so we cannot MarkFlagRequired it on this subcommand.
+ organization, err = cmd.Flags().GetString("org")
+ if err != nil {
+ 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")
- }
- if workflowFlag == "" {
- return fmt.Errorf("--workflow is required for trace run")
- }
+ // 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")
+ }
+ if workflowFlag == "" {
+ return fmt.Errorf("--workflow is required for trace run")
+ }
+
+ contractName, contractRequired = config.ResolveContract(contractFlag)
- contractName, contractRequired := config.ResolveContract(contractFlag)
+ // Build ActionOpts lazily (this command is skipActionOptsInit),
+ // so a LOCAL run never opens a control-plane connection.
+ if err := ensureActionOpts(cmd); err != nil {
+ return err
+ }
+ }
return action.TraceRun(cmd.Context(), logger, action.TraceRunOpts{
Store: store,
@@ -124,20 +160,21 @@ func newTraceRunCmd() *cobra.Command {
ContractRequired: contractRequired,
ActionOpts: ActionOpts,
CLIVersion: Version,
+ Local: local,
})
},
}
- 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 the repository is in local mode; .chainloop.yml identity is ignored)")
+ cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name used for trace attestations (required unless the repository is in local mode; .chainloop.yml identity is ignored)")
cmd.Flags().StringVar(&versionFlag, "version", "", "chainloop project version (optional; defaults to the latest version)")
cmd.Flags().StringVar(&contractFlag, "contract", "", traceContractFlagDesc)
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")
+ // project and workflow are not required in local mode, so the check is done
+ // in RunE (after the storage mode is known) rather than by cobra up front.
return cmd
}
diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md
index 9ecf4b11a..7f31d908c 100644
--- a/app/cli/documentation/cli-reference.md
+++ b/app/cli/documentation/cli-reference.md
@@ -3322,6 +3322,14 @@ 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 for local mode: it installs the same
+hooks but creates no workflow, needs no login, and reaches no control plane. It
+writes traceStorage: LOCAL to .chainloop.yml (and tracePath when --trace-path is
+given), so every clone records sessions on disk. The pre-push hook then stores a
+session's evidence under the path instead of pushing an attestation. --contract,
+--workflow, --org and --project are control-plane settings and cannot be used
+with --local; --require-trace can.
+
```
chainloop trace init [flags]
```
@@ -3329,14 +3337,16 @@ chainloop trace init [flags]
Options
```
---claude install Claude Code hooks (default when no harness 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
--h, --help help for init
---opencode install opencode hooks
---project string chainloop project name
---require-trace block pushes when attestation fails for AI-assisted commits
---workflow string chainloop workflow name used for trace attestations (defaults to "ai-coding-session")
+--claude install Claude Code hooks (default when no harness 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
+-h, --help help for init
+--local set up local mode: no control plane, no login, no workflow; the pre-push hook stores session evidence on disk
+--opencode install opencode hooks
+--project string chainloop project name
+--require-trace block pushes when a required trace is missing
+--trace-path string where local-mode sessions are stored (default: under .git/chainloop-trace/local-sessions); implies --local
+--workflow string chainloop workflow name used for trace attestations (defaults to "ai-coding-session")
```
Options inherited from parent commands
@@ -3366,11 +3376,15 @@ Wrap an external command (typically an AI coding agent) and emit a
Chainloop attestation for the session once the command exits successfully.
Sessions without git commits are still attested.
-trace run is isolated: it ignores .chainloop.yml entirely. The attestation
-identity must come from the --org, --project, --workflow flags (mandatory)
-and the optional --version flag. It does not read from or write to
-.chainloop.yml, and removes every hook and local trace artifact when the
-wrapped command exits, so a session never leaks setup into the next one.
+trace run keeps its identity isolated from .chainloop.yml: the attestation
+--org, --project and --workflow come from the flags (mandatory) and the
+optional --version flag, never from the file. It does read the repository's
+traceStorage mode, though: in a local-mode repository (traceStorage: LOCAL) the
+session is written to disk when the command exits, with no control plane, and
+the --org, --project and --workflow flags are not required. Either way it
+removes the temporary hooks and trace state when the wrapped command exits while
+preserving stored local session evidence, 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
@@ -3388,9 +3402,9 @@ Options
--cursor install Cursor hooks
-h, --help help for run
--opencode install opencode hooks
---project string chainloop project name (required; .chainloop.yml is ignored)
+--project string chainloop project name (required unless the repository is in local mode; .chainloop.yml identity 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 the repository is in local mode; .chainloop.yml identity is ignored)
```
Options inherited from parent commands
diff --git a/app/cli/internal/repositoryconfig/config.go b/app/cli/internal/repositoryconfig/config.go
index 36e9c8c49..9382d7061 100644
--- a/app/cli/internal/repositoryconfig/config.go
+++ b/app/cli/internal/repositoryconfig/config.go
@@ -39,8 +39,23 @@ type ChainloopYML struct {
ProjectName string `yaml:"projectName"`
ProjectVersion string `yaml:"projectVersion"`
Organization string `yaml:"organization,omitempty"`
- RequireTrace *bool `yaml:"requireTrace,omitempty"`
WorkflowName string `yaml:"workflowName,omitempty"`
+
+ // TraceStorage selects where Chainloop Trace stores a session's evidence:
+ // "CONTROL_PLANE" (the default) uploads a signed attestation, "LOCAL" writes
+ // the evidence set to disk with no control plane. It is a committed, shared
+ // repository policy, so a local-trace repository is the same for every clone.
+ TraceStorage string `yaml:"traceStorage,omitempty"`
+ // TracePath names where local sessions are stored in LOCAL mode. Empty falls
+ // back to a per-clone default under the trace state directory.
+ TracePath string `yaml:"tracePath,omitempty"`
+ // TraceRequired blocks a push when a required trace is missing. It replaces
+ // the deprecated RequireTrace with the same meaning.
+ TraceRequired *bool `yaml:"traceRequired,omitempty"`
+
+ // RequireTrace is deprecated in favor of TraceRequired and kept only so an
+ // older .chainloop.yml keeps working until it is dropped.
+ RequireTrace *bool `yaml:"requireTrace,omitempty"`
}
// LoadChainloopYML loads the nearest .chainloop.yml (or .chainloop.yaml),
diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md
index 6072463ae..ca2d3d05c 100644
--- a/app/cli/internal/trace/README.md
+++ b/app/cli/internal/trace/README.md
@@ -47,7 +47,7 @@ What the user sees in the agent.
| Feature | What it means | Claude Code | Cursor | OpenCode 1.x | OpenCode 2 |
|---------|---------------|-------------|--------|--------------|------------|
-| Welcome message | At session start, the user sees a message that tells that Chainloop records the session, and in which project. | Yes | No | No | No |
+| Welcome message | At session start, the user sees a message that tells that Chainloop records the session, and where the evidence goes: the Chainloop project, or the on-disk path in local mode (spec issue-3582). | Yes | No | No | No |
| Session link after push | After a `git push` that the agent runs, the user sees a link to the session in Chainloop. | Yes | No | No | No |
Why not Yes:
@@ -99,7 +99,8 @@ How the sessions are linked to the commits and attested.
| Feature | What it means | Claude Code | Cursor | OpenCode 1.x | OpenCode 2 |
|---------|---------------|-------------|--------|--------------|------------|
| 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 |
+| Attestation at push | In the default control-plane storage mode, the `pre-push` hook attests the sessions of the pushed commits to the control plane. | Yes | Yes | Yes | Yes |
+| Local storage at push | In a repository with `traceStorage: LOCAL` in `.chainloop.yml`, the `pre-push` hook runs the same assembly but writes the session evidence set to disk under `tracePath` (default `.git/chainloop-trace/local-sessions`) instead of attesting, so it needs no control plane, login, or network. The stored set equals what a push would upload, apart from the signature, and a `manifest.json` marks it unsigned and not an attestation. `trace init --local` sets the mode up; `trace run` honors it. Redaction is on by default, with the `CHAINLOOP_TRACE_NO_REDACT` opt-out for a trusted local store (spec issue-3582). | Yes | Yes | Yes | Yes |
## How each agent is hooked in
diff --git a/app/cli/internal/trace/config/config.go b/app/cli/internal/trace/config/config.go
index 55191ab79..05c9bd281 100644
--- a/app/cli/internal/trace/config/config.go
+++ b/app/cli/internal/trace/config/config.go
@@ -17,6 +17,7 @@ package config
import (
"bytes"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -77,10 +78,113 @@ func SaveProjectToYML(dir, project string) error {
// SaveRequireTraceToYML writes (or updates) the requireTrace field in
// .chainloop.yml at the given directory, preserving any other fields.
+//
+// Deprecated: write traceRequired with SaveTraceRequiredToYML. This remains for
+// the legacy path until requireTrace is dropped.
func SaveRequireTraceToYML(dir string, val bool) error {
return updateChainloopYMLField(dir, "requireTrace", val)
}
+const (
+ // TraceStorageControlPlane uploads a signed attestation to the control
+ // plane. It is the mode when traceStorage is unset.
+ TraceStorageControlPlane = "CONTROL_PLANE"
+ // TraceStorageLocal writes the session evidence set to disk, with no
+ // control plane.
+ TraceStorageLocal = "LOCAL"
+)
+
+// ResolveTraceStorage canonicalizes a traceStorage value: surrounding space is
+// trimmed and the name upper-cased, an empty value is CONTROL_PLANE, and any
+// value other than the two known modes is an error, so a committed typo surfaces
+// instead of silently falling back to the control plane (and pushing to a
+// backend a LOCAL repository means to avoid).
+func ResolveTraceStorage(persisted string) (string, error) {
+ switch v := strings.ToUpper(strings.TrimSpace(persisted)); v {
+ case "":
+ return TraceStorageControlPlane, nil
+ case TraceStorageControlPlane, TraceStorageLocal:
+ return v, nil
+ default:
+ return "", fmt.Errorf("unknown traceStorage %q in .chainloop.yml: want %s or %s", persisted, TraceStorageControlPlane, TraceStorageLocal)
+ }
+}
+
+// LoadTraceStorageFromYML reads and canonicalizes the traceStorage mode from the
+// nearest .chainloop.yml. A missing config or field is CONTROL_PLANE, but a
+// config that exists and cannot be read or parsed is an error, never a silent
+// CONTROL_PLANE. A LOCAL repository whose config broke must not have its
+// pre-push hook fall back to the control plane and upload a local-only session.
+func LoadTraceStorageFromYML(dir string) (string, error) {
+ cfg, _, err := repositoryconfig.LoadChainloopYML(dir)
+ if errors.Is(err, repositoryconfig.ErrChainloopYMLNotFound) {
+ return TraceStorageControlPlane, nil
+ }
+ if err != nil {
+ return "", err
+ }
+
+ return ResolveTraceStorage(cfg.TraceStorage)
+}
+
+// IsLocalStorage reports whether the repository is configured for LOCAL trace
+// storage. It is best-effort: a missing config, or an invalid value, is treated
+// as not-local, so a caller that only shapes local-mode behavior (a banner, a
+// self-heal) falls back to the control-plane path. The pre-push hook calls
+// LoadTraceStorageFromYML directly instead, so an invalid value blocks the push
+// rather than being ignored.
+func IsLocalStorage(dir string) bool {
+ mode, err := LoadTraceStorageFromYML(dir)
+ return err == nil && mode == TraceStorageLocal
+}
+
+// SaveTraceStorageToYML writes (or updates) the traceStorage field in
+// .chainloop.yml at the given directory, preserving any other fields.
+func SaveTraceStorageToYML(dir, mode string) error {
+ return updateChainloopYMLField(dir, "traceStorage", mode)
+}
+
+// SaveTracePathToYML writes (or updates) the tracePath field in .chainloop.yml
+// at the given directory, preserving any other fields.
+func SaveTracePathToYML(dir, path string) error {
+ return updateChainloopYMLField(dir, "tracePath", path)
+}
+
+// LoadTracePathFromYML looks for .chainloop.yml starting from dir and returns
+// the tracePath value. Returns empty string when the field is absent.
+func LoadTracePathFromYML(dir string) string {
+ cfg := repositoryconfig.FindChainloopYML(dir)
+ if cfg == nil {
+ return ""
+ }
+
+ return cfg.TracePath
+}
+
+// SaveTraceRequiredToYML writes (or updates) the traceRequired field in
+// .chainloop.yml at the given directory, preserving any other fields.
+func SaveTraceRequiredToYML(dir string, val bool) error {
+ return updateChainloopYMLField(dir, "traceRequired", val)
+}
+
+// LoadTraceRequiredFromYML reports whether a required trace must block a push.
+// It prefers traceRequired and falls back to the deprecated requireTrace, so an
+// older .chainloop.yml keeps working. Absent in both is false (default-off).
+func LoadTraceRequiredFromYML(dir string) bool {
+ cfg := repositoryconfig.FindChainloopYML(dir)
+ if cfg == nil {
+ return false
+ }
+ if cfg.TraceRequired != nil {
+ return *cfg.TraceRequired
+ }
+ if cfg.RequireTrace != nil {
+ return *cfg.RequireTrace
+ }
+
+ return false
+}
+
// SaveOrganizationToYML writes (or updates) the organization field in
// .chainloop.yml at the given directory, preserving any other fields.
func SaveOrganizationToYML(dir, org string) error {
diff --git a/app/cli/internal/trace/config/storage_test.go b/app/cli/internal/trace/config/storage_test.go
new file mode 100644
index 000000000..db26679e8
--- /dev/null
+++ b/app/cli/internal/trace/config/storage_test.go
@@ -0,0 +1,158 @@
+//
+// 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 config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveTraceStorage(t *testing.T) {
+ testCases := []struct {
+ name string
+ persisted string
+ want string
+ wantErr bool
+ }{
+ {name: "empty defaults to control plane", persisted: "", want: TraceStorageControlPlane},
+ {name: "control plane", persisted: "CONTROL_PLANE", want: TraceStorageControlPlane},
+ {name: "local", persisted: "LOCAL", want: TraceStorageLocal},
+ {name: "lower-case is canonicalized", persisted: "local", want: TraceStorageLocal},
+ {name: "surrounding space is trimmed", persisted: " LOCAL ", want: TraceStorageLocal},
+ {name: "mixed case", persisted: "Control_Plane", want: TraceStorageControlPlane},
+ {name: "unknown value is an error", persisted: "LOCALL", wantErr: true},
+ {name: "a non-empty typo does not fall back", persisted: "disk", wantErr: true},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := ResolveTraceStorage(tc.persisted)
+ if tc.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestLoadTraceStorageFromYML(t *testing.T) {
+ t.Run("missing config is control plane", func(t *testing.T) {
+ mode, err := LoadTraceStorageFromYML(t.TempDir())
+ require.NoError(t, err)
+ assert.Equal(t, TraceStorageControlPlane, mode)
+ })
+
+ t.Run("reads and canonicalizes local", func(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"),
+ []byte("projectName: p\ntraceStorage: local\n"), 0600))
+
+ mode, err := LoadTraceStorageFromYML(dir)
+ require.NoError(t, err)
+ assert.Equal(t, TraceStorageLocal, mode)
+ assert.True(t, IsLocalStorage(dir))
+ })
+
+ t.Run("invalid value surfaces an error", func(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"),
+ []byte("traceStorage: nope\n"), 0600))
+
+ _, err := LoadTraceStorageFromYML(dir)
+ assert.Error(t, err)
+ // Best-effort callers treat an invalid value as not-local.
+ assert.False(t, IsLocalStorage(dir))
+ })
+
+ t.Run("a malformed config is an error, not a silent control plane", func(t *testing.T) {
+ // A LOCAL repository whose config broke must not fall back to the
+ // control plane and upload a local-only session: the pre-push hook
+ // reads this and a silent CONTROL_PLANE would do exactly that.
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"),
+ []byte("traceStorage: [not, a, string]\n"), 0600))
+
+ mode, err := LoadTraceStorageFromYML(dir)
+ assert.Error(t, err)
+ assert.NotEqual(t, TraceStorageControlPlane, mode)
+ })
+}
+
+func TestTraceStorageRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"),
+ []byte("projectName: p\nprojectVersion: v1.0.0\n"), 0600))
+
+ require.NoError(t, SaveTraceStorageToYML(dir, TraceStorageLocal))
+ require.NoError(t, SaveTracePathToYML(dir, "evidence/sessions"))
+
+ assert.True(t, IsLocalStorage(dir))
+ assert.Equal(t, "evidence/sessions", LoadTracePathFromYML(dir))
+
+ // Other fields survive.
+ data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yml"))
+ require.NoError(t, err)
+ assert.Contains(t, string(data), "projectName: p")
+ assert.Contains(t, string(data), "projectVersion: v1.0.0")
+}
+
+func TestLoadTraceRequiredFromYML(t *testing.T) {
+ testCases := []struct {
+ name string
+ yml string
+ want bool
+ }{
+ {name: "absent in both is false", yml: "projectName: p\n", want: false},
+ {name: "traceRequired true", yml: "traceRequired: true\n", want: true},
+ {name: "traceRequired false", yml: "traceRequired: false\n", want: false},
+ {name: "falls back to deprecated requireTrace", yml: "requireTrace: true\n", want: true},
+ {
+ name: "traceRequired wins over requireTrace",
+ yml: "traceRequired: false\nrequireTrace: true\n",
+ want: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte(tc.yml), 0600))
+ assert.Equal(t, tc.want, LoadTraceRequiredFromYML(dir))
+ })
+ }
+
+ t.Run("no config file is false", func(t *testing.T) {
+ assert.False(t, LoadTraceRequiredFromYML(t.TempDir()))
+ })
+
+ t.Run("save writes traceRequired and round-trips", func(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: p\n"), 0600))
+
+ require.NoError(t, SaveTraceRequiredToYML(dir, true))
+ assert.True(t, LoadTraceRequiredFromYML(dir))
+
+ data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yml"))
+ require.NoError(t, err)
+ assert.Contains(t, string(data), "traceRequired: true")
+ })
+}
diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go
index f73564214..93ba9c17e 100644
--- a/app/cli/internal/trace/state/state.go
+++ b/app/cli/internal/trace/state/state.go
@@ -44,6 +44,11 @@ const (
traceDirCommits = "commits"
traceDirSnapshots = "snapshots"
traceDirAILines = "ai-lines"
+ // traceDirLocalSessions holds the evidence sets written by LOCAL trace
+ // storage: one subdirectory per session, each with its materials and a
+ // manifest. Unlike raw/ and snapshots/ it is durable user data, so it is
+ // never touched by WipeTraceDir, GCOrphans, or RemoveTraceDir.
+ traceDirLocalSessions = "local-sessions"
// 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.
@@ -100,6 +105,14 @@ func (s *Store) SpecRedactionDir(sessionID string) string {
return filepath.Join(s.traceDirPath(), traceDirSpecRedactions, SanitizeID(sessionID))
}
+// LocalSessionsDir returns the default directory LOCAL trace storage writes its
+// evidence sets to when .chainloop.yml sets no tracePath. It sits beside the
+// other trace state inside .git/chainloop-trace, so it is never committed, and
+// it is durable: the per-push WipeTraceDir and GCOrphans do not touch it.
+func (s *Store) LocalSessionsDir() string {
+ return filepath.Join(s.traceDirPath(), traceDirLocalSessions)
+}
+
// RecordedSpecFiles returns the names of the spec files that the last push of
// a session stored, in the order that push read them. A session that
// never pushed a spec has none.
@@ -334,9 +347,84 @@ func (s *Store) staleSessionIDs(unattested map[string]struct{}) map[string]struc
return stale
}
-// RemoveTraceDir removes the entire
/chainloop-trace/ directory.
-func (s *Store) RemoveTraceDir() error {
- return os.RemoveAll(s.traceDirPath())
+// RemoveTraceDir removes the trace state but preserves local-sessions/ and any
+// preservePaths that resolve inside the trace directory, the durable evidence
+// sets a LOCAL-mode push or `trace run` wrote. A developer keeps those as they
+// keep any working copy of a session, so trace uninstall and the trace-run
+// teardown must not delete them. A configured tracePath can point at a folder
+// inside the trace directory under a name other than local-sessions, so the
+// caller passes it here to keep it from the cleanup. An ordinary repository with
+// no stored sessions is left as clean as before: the trace directory is removed
+// once nothing but the (absent) sessions would remain.
+func (s *Store) RemoveTraceDir(preservePaths ...string) error {
+ base := s.traceDirPath()
+
+ keep := map[string]struct{}{traceDirLocalSessions: {}}
+ for _, p := range preservePaths {
+ if name := topLevelEntryUnder(base, p); name != "" {
+ keep[name] = struct{}{}
+ }
+ }
+
+ entries, err := os.ReadDir(base)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+
+ return err
+ }
+
+ for _, e := range entries {
+ if _, ok := keep[e.Name()]; ok {
+ continue
+ }
+ if err := os.RemoveAll(filepath.Join(base, e.Name())); err != nil {
+ return err
+ }
+ }
+
+ // Drop the trace directory itself only when the preserved sessions did not
+ // keep it alive.
+ if remaining, err := os.ReadDir(base); err == nil && len(remaining) == 0 {
+ return os.Remove(base)
+ }
+
+ return nil
+}
+
+// topLevelEntryUnder returns the first path element of target relative to base,
+// or "" when target is empty or does not resolve inside base. RemoveTraceDir
+// uses it to map a configured storage path to the single trace-directory entry
+// that must survive cleanup. Both paths are made absolute first, so a relative
+// base (a git store's .git) and an absolute tracePath still compare correctly.
+func topLevelEntryUnder(base, target string) string {
+ if target == "" {
+ return ""
+ }
+
+ absBase, err := filepath.Abs(base)
+ if err != nil {
+ return ""
+ }
+ absTarget, err := filepath.Abs(target)
+ if err != nil {
+ return ""
+ }
+
+ rel, err := filepath.Rel(absBase, absTarget)
+ if err != nil {
+ return ""
+ }
+ if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return ""
+ }
+
+ if i := strings.IndexRune(rel, filepath.Separator); i >= 0 {
+ return rel[:i]
+ }
+
+ return rel
}
// LogFilePath returns the path to the hook log file inside the trace directory.
diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go
index 66cc6abf6..e22c9f2bb 100644
--- a/app/cli/internal/trace/state/state_test.go
+++ b/app/cli/internal/trace/state/state_test.go
@@ -109,6 +109,66 @@ func TestWipeTraceDir(t *testing.T) {
"ai-lines must survive the wipe so post-rebase attribution still works")
}
+// TestRemoveTraceDirPreservesLocalSessions pins that RemoveTraceDir keeps the
+// local-sessions directory — the durable evidence a LOCAL-mode push or
+// `trace run` wrote — while clearing the rest of the state, so trace uninstall
+// and the trace-run teardown never destroy a developer's stored sessions.
+func TestRemoveTraceDirPreservesLocalSessions(t *testing.T) {
+ store := NewGitStore(t.TempDir())
+ require.NoError(t, store.InitTraceDir())
+ require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "abc123", SessionIDs: []string{"sess-1"}, Timestamp: "2026-03-28T00:00:00Z"}))
+
+ // A stored session on disk, as LOCAL storage would write it.
+ sessionDir := filepath.Join(store.LocalSessionsDir(), "sess-1")
+ require.NoError(t, os.MkdirAll(sessionDir, 0o700))
+ require.NoError(t, os.WriteFile(filepath.Join(sessionDir, "manifest.json"), []byte("{}"), 0o600))
+
+ require.NoError(t, store.RemoveTraceDir())
+
+ // The stored sessions survive.
+ data, err := os.ReadFile(filepath.Join(sessionDir, "manifest.json"))
+ require.NoError(t, err)
+ assert.Equal(t, "{}", string(data))
+
+ // Everything else is gone.
+ commits, err := store.LoadAllCommitRecords()
+ require.NoError(t, err)
+ assert.Empty(t, commits, "non-local state is removed")
+
+ t.Run("an ordinary repository with no stored sessions removes the trace dir", func(t *testing.T) {
+ plain := NewGitStore(t.TempDir())
+ require.NoError(t, plain.InitTraceDir())
+ require.NoError(t, plain.RemoveTraceDir())
+ _, err := os.Stat(plain.LocalSessionsDir())
+ assert.True(t, os.IsNotExist(err))
+ })
+
+ t.Run("a custom tracePath inside the trace dir is preserved", func(t *testing.T) {
+ s := NewGitStore(t.TempDir())
+ require.NoError(t, s.InitTraceDir())
+
+ // A configured tracePath that lands inside the trace directory under a
+ // name other than local-sessions. Without being passed to
+ // RemoveTraceDir it would be deleted with the rest of the state.
+ base := filepath.Join(s.Dir(), "chainloop-trace")
+ custom := filepath.Join(base, "my-sessions", "sess-9")
+ require.NoError(t, os.MkdirAll(custom, 0o700))
+ require.NoError(t, os.WriteFile(filepath.Join(custom, "manifest.json"), []byte("{}"), 0o600))
+
+ // A sibling entry that is neither local-sessions nor the custom path
+ // must still be removed.
+ stale := filepath.Join(base, "sessions", "sess-stale")
+ require.NoError(t, os.MkdirAll(stale, 0o700))
+
+ require.NoError(t, s.RemoveTraceDir(filepath.Join(base, "my-sessions")))
+
+ _, err := os.Stat(filepath.Join(custom, "manifest.json"))
+ assert.NoError(t, err, "the configured storage path survives")
+ _, err = os.Stat(stale)
+ assert.True(t, os.IsNotExist(err), "unrelated state is still removed")
+ })
+}
+
func TestGCOrphans(t *testing.T) {
store := NewGitStore(t.TempDir())
require.NoError(t, store.InitTraceDir())
diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go
index ace543ffc..25cd6ac18 100644
--- a/app/cli/pkg/action/trace_agent_hook.go
+++ b/app/cli/pkg/action/trace_agent_hook.go
@@ -150,6 +150,26 @@ func sessionStartBanner(dashboardURL, org, project string) string {
return banner
}
+// localSessionStartBanner is the session-start banner for a local-mode
+// repository. It names the local mode outright and says where the evidence
+// lands, with no dashboard or attestation vocabulary and no control-plane round
+// trip: local mode has no dashboard to point at (R-012).
+func localSessionStartBanner(tracePath string, traceRunActive bool) string {
+ banner := "Chainloop Trace is recording this session in local mode."
+ if tracePath != "" {
+ // A git-hook session stores its evidence on the next push. A `trace run`
+ // session stores it as soon as the wrapped command exits, so the timing
+ // has to match how the session is driven, not always claim a push.
+ when := "when you push"
+ if traceRunActive {
+ when = "when this run finishes"
+ }
+ banner += "\nIts evidence will be stored on disk at " + tracePath + " " + when + "."
+ }
+
+ return banner
+}
+
// HandleAgentSessionStart handles the agent session-start hook.
func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error {
input, err := provider.ReadHookInput(os.Stdin)
@@ -186,11 +206,21 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error
}
if provider.SupportsSessionStartBanner() {
- banner := sessionStartBanner(
- hookDashboardURL(log),
- config.LoadOrganizationFromYML(repoRoot),
- repositoryconfig.LoadProjectFromYML(repoRoot),
- )
+ var banner string
+ if config.IsLocalStorage(repoRoot) {
+ // Local mode reaches no control plane, so the banner names the
+ // on-disk destination and makes no dashboard round trip (R-012).
+ banner = localSessionStartBanner(
+ ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(repoRoot)),
+ store.IsTraceRunActive(),
+ )
+ } else {
+ banner = sessionStartBanner(
+ hookDashboardURL(log),
+ config.LoadOrganizationFromYML(repoRoot),
+ repositoryconfig.LoadProjectFromYML(repoRoot),
+ )
+ }
msg.Banner = "\n\n" + banner + "\n"
}
@@ -752,8 +782,12 @@ func autoInstallGitHooks(store *state.Store, repoRoot string, log zerolog.Logger
return
}
- if repositoryconfig.LoadProjectFromYML(repoRoot) == "" {
- return // can't auto-install without a project name
+ // Auto-install needs a repository that is set up for trace: a control-plane
+ // project name, or local storage mode. A local-mode repository writes no
+ // projectName, so without the local check its hooks would never self-heal
+ // after a re-clone or a manual delete.
+ if repositoryconfig.LoadProjectFromYML(repoRoot) == "" && !config.IsLocalStorage(repoRoot) {
+ return
}
if _, err := hooks.Install(gitDir, skipPrePush); err != nil {
diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go
index 7d1ffd869..ce1292149 100644
--- a/app/cli/pkg/action/trace_hook_handler.go
+++ b/app/cli/pkg/action/trace_hook_handler.go
@@ -536,6 +536,38 @@ func HandlePrePushHook(ctx context.Context, log zerolog.Logger, opts RunTracePus
return RunTracePush(ctx, log, opts)
}
+// HandlePrePushHookLocal stores the recorded session evidence to disk instead of
+// pushing an attestation, for a repository configured with traceStorage: LOCAL
+// (R-009). It makes no control-plane call. Like the control-plane path it drains
+// the push stdin first, to avoid a SIGPIPE on the git side, then runs the same
+// assembly and writes the evidence under the configured tracePath. It tells the
+// user where the session went (R-012).
+//
+// "Nothing recorded" is not an error, so a human-only push is never blocked;
+// only a genuine store failure is returned, which the caller turns into a
+// blocked push when traceRequired is set.
+func HandlePrePushHookLocal(ctx context.Context, log zerolog.Logger) error {
+ drainPushStdin()
+
+ stored, tracePath, err := RunTraceLocalStore(ctx, log, RunTraceLocalStoreOpts{
+ NoRedact: traceNoRedactFromEnv(),
+ Mode: aicodingsession.ModeCoding,
+ })
+ if err != nil {
+ return fmt.Errorf("store session evidence locally: %w", err)
+ }
+
+ if stored == 0 {
+ log.Info().Msg("no new AI coding session evidence, nothing stored")
+
+ return nil
+ }
+
+ log.Info().Int("sessions", stored).Msgf("AI coding session evidence stored at %s", tracePath)
+
+ return nil
+}
+
// 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
@@ -571,89 +603,12 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts
log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot).
Bool("allow_empty", opts.AllowEmpty).Str("mode", sessionMode).Msg("trace push invoked")
- allCommits, err := store.LoadAllCommitRecords()
+ sessions, aiCommits, err := gatherPushSessions(ctx, store, repoRoot, sessionMode, opts.AllowEmpty, log)
if err != nil {
- return fmt.Errorf("load commit records: %w", err)
- }
- log.Debug().Int("commit_count", len(allCommits)).Msg("loaded commit records")
-
- gitClient := tracegit.NewGoGitClient()
-
- // Drop orphan records (SHAs no longer in the branch — typically left over
- // by a rebase) and any record that turns out to belong to a merge commit.
- allCommits = filterCurrentBranchCommits(gitClient, repoRoot, allCommits, log)
-
- // Discard commits without any session IDs (no AI agent involved)
- var aiCommits []*state.CommitRecord
- var untrackedAICommits int
- for _, c := range allCommits {
- if len(c.SessionIDs) > 0 {
- aiCommits = append(aiCommits, c)
- if !c.Tracked {
- untrackedAICommits++
- }
- }
- }
-
- if len(aiCommits) == 0 && !opts.AllowEmpty {
- log.Info().Msg("no AI-assisted commits, skipping attestation")
-
- return nil
- }
-
- // If every candidate AI commit has already been attested by a previous
- // successful push, there is nothing new to report. Short-circuit to avoid
- // emitting a duplicate attestation on no-op `git push` invocations
- // (including `git tag && git push --tags` over already-pushed commits).
- if len(aiCommits) > 0 && untrackedAICommits == 0 && !opts.AllowEmpty {
- log.Info().Int("ai_commit_count", len(aiCommits)).Msg("all AI-assisted commits already attested, skipping push")
-
- 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")
- }
-
- 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")
-
- return nil
- }
- log.Info().Int("session_count", len(sessionCommits)).Msg("no AI-assisted commits; attesting session evidence only")
- } else {
- log.Info().Int("ai_commit_count", len(aiCommits)).Msg("parsing AI-assisted commit records")
+ return err
}
-
- log.Debug().Int("session_count", len(sessionCommits)).Msg("grouped commits by session")
-
- sessions := buildSessionEvidence(ctx, store, repoRoot, gitClient, sessionCommits, sessionRecords, sessionMode, log)
-
if len(sessions) == 0 {
- log.Debug().Msg("no session evidence could be generated")
+ log.Info().Msg("no new AI coding session evidence, skipping attestation")
return nil
}
@@ -698,7 +653,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")
@@ -743,10 +698,119 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts
recordPushedSpecs(store, sessionID, fileNames, log)
}
- // Mark every AI commit included in this attestation as tracked so that a
- // subsequent `git push` with no new commits short-circuits at the skip
- // check above. Save errors are non-fatal: the attestation already went
- // out, and at worst we re-attest the same commits on the next push.
+ finalizePush(store, repoRoot, aiCommits, log)
+
+ return nil
+}
+
+// sessionCommitGroups groups the AI commits by the sessions that contributed to
+// them, each group sorted oldest first. With allowEmpty and no AI commits it
+// falls back to the active sessions, so their evidence is assembled even when
+// they produced no commit on this branch (the trace-run and local-store paths).
+// A session that has ended without a commit is left out: assembling 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
+}
+
+// gatherPushSessions runs the shared front half of a push: it loads the commit
+// records, drops those not on the current branch, applies the idempotency
+// short-circuits, groups the AI commits by session, and assembles each
+// session's evidence. It returns the assembled sessions and the AI commits that
+// back them, which finalizePush later marks tracked. An empty session slice
+// means there is nothing new to record (no AI commits, or every one already
+// tracked); the caller decides what to tell the user. allowEmpty mirrors
+// RunTracePushOpts.AllowEmpty. The assembly (buildSessionEvidence) is shared by
+// the control-plane push and the local store, so the two cannot drift (D-001).
+func gatherPushSessions(ctx context.Context, store *state.Store, repoRoot, sessionMode string, allowEmpty bool, log zerolog.Logger) ([]sessionEvidence, []*state.CommitRecord, error) {
+ allCommits, err := store.LoadAllCommitRecords()
+ if err != nil {
+ return nil, nil, fmt.Errorf("load commit records: %w", err)
+ }
+ log.Debug().Int("commit_count", len(allCommits)).Msg("loaded commit records")
+
+ gitClient := tracegit.NewGoGitClient()
+
+ // Drop orphan records (SHAs no longer in the branch — typically left over
+ // by a rebase) and any record that turns out to belong to a merge commit.
+ allCommits = filterCurrentBranchCommits(gitClient, repoRoot, allCommits, log)
+
+ // Discard commits without any session IDs (no AI agent involved).
+ var aiCommits []*state.CommitRecord
+ var untrackedAICommits int
+ for _, c := range allCommits {
+ if len(c.SessionIDs) > 0 {
+ aiCommits = append(aiCommits, c)
+ if !c.Tracked {
+ untrackedAICommits++
+ }
+ }
+ }
+
+ if len(aiCommits) == 0 && !allowEmpty {
+ log.Debug().Msg("no AI-assisted commits")
+
+ return nil, nil, nil
+ }
+
+ // If every candidate AI commit has already been recorded by a previous
+ // successful run, there is nothing new. Short-circuit so a no-op `git push`
+ // does not re-record the same evidence.
+ if len(aiCommits) > 0 && untrackedAICommits == 0 && !allowEmpty {
+ log.Debug().Int("ai_commit_count", len(aiCommits)).Msg("all AI-assisted commits already recorded")
+
+ return nil, aiCommits, nil
+ }
+
+ sessionRecords, err := store.LoadAllSessionRecords()
+ if err != nil {
+ log.Debug().Err(err).Msg("could not load session records")
+ }
+
+ sessionCommits := sessionCommitGroups(aiCommits, sessionRecords, allowEmpty)
+ if len(sessionCommits) == 0 {
+ log.Debug().Msg("no AI coding sessions to record")
+
+ return nil, aiCommits, nil
+ }
+ log.Debug().Int("session_count", len(sessionCommits)).Msg("grouped commits by session")
+
+ sessions := buildSessionEvidence(ctx, store, repoRoot, gitClient, sessionCommits, sessionRecords, sessionMode, log)
+ if len(sessions) == 0 {
+ log.Debug().Msg("no session evidence could be generated")
+ }
+
+ return sessions, aiCommits, nil
+}
+
+// finalizePush marks the AI commits recorded by this run as tracked and prunes
+// the single-use trace state, so a later `git push` with no new commits
+// short-circuits in gatherPushSessions rather than re-recording. Every step is
+// best-effort: the evidence is already out, and at worst the next run repeats
+// the work. It is shared by the control-plane push and the local store, so both
+// get the same idempotency.
+func finalizePush(store *state.Store, repoRoot string, aiCommits []*state.CommitRecord, log zerolog.Logger) {
for _, c := range aiCommits {
if c.Tracked {
continue
@@ -758,6 +822,8 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts
}
_ = store.WipeTraceDir()
+
+ gitClient := tracegit.NewGoGitClient()
if liveSHAs, err := gitClient.LocalReachableSHAs(repoRoot); err == nil {
if err := store.GCOrphans(liveSHAs); err != nil {
log.Debug().Err(err).Msg("orphan GC failed; trace state is intact but not pruned")
@@ -765,8 +831,6 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts
} else {
log.Debug().Err(err).Msg("could not enumerate local branch SHAs; skipping orphan GC")
}
-
- return nil
}
// sessionEvidence is the evidence of one session before it is added to the
@@ -900,7 +964,10 @@ 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) {
+// noRedact disables secret redaction, for a trusted local store only (R-004);
+// a push always passes false. It threads into both the spec/skill redactor here
+// and, through the adder, the session transcript's own redaction.
+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 +975,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_local_store.go b/app/cli/pkg/action/trace_local_store.go
new file mode 100644
index 000000000..c471f94d2
--- /dev/null
+++ b/app/cli/pkg/action/trace_local_store.go
@@ -0,0 +1,378 @@
+//
+// 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"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config"
+ "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"
+)
+
+// traceNoRedactEnv turns secret redaction off for a local store. It is
+// deliberately not a .chainloop.yml field: a committed no-redact would disable
+// redaction for every teammate who clones the repository. The opt-out is per-run
+// and for a trusted local store only, whose output can then hold secrets (R-004).
+const traceNoRedactEnv = "CHAINLOOP_TRACE_NO_REDACT"
+
+// traceNoRedactFromEnv reports whether CHAINLOOP_TRACE_NO_REDACT is set to a
+// truthy value (1, t, true, …).
+func traceNoRedactFromEnv() bool {
+ v, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(traceNoRedactEnv)))
+ return err == nil && v
+}
+
+const (
+ // manifestFileName is the index a reader opens first in a stored session.
+ manifestFileName = "manifest.json"
+ // materialsDirName holds every material of a session, each file named by its
+ // content digest so a reader and a test find it from the reference in the
+ // evidence record.
+ materialsDirName = "materials"
+)
+
+// RunTraceLocalStoreOpts configures a disk store of the recorded session
+// evidence.
+type RunTraceLocalStoreOpts struct {
+ // NoRedact disables secret redaction. Off by default: the store redacts
+ // secrets exactly as a push does. The opt-out is for a trusted local run,
+ // and the output can then hold secrets (R-004).
+ NoRedact bool
+ // Mode records how the session was driven, one of the aicodingsession.Mode*
+ // constants. Empty means ModeCoding.
+ Mode string
+ // AllowEmpty assembles active sessions that produced no commit on this
+ // branch, as `chainloop trace run` does. The pre-push path leaves it off, so
+ // a push with no AI commits stores nothing.
+ AllowEmpty bool
+}
+
+// RunTraceLocalStore runs the same push-time assembly that RunTracePush drives
+// but writes the resulting evidence to disk 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, apart from the
+// signature (R-001/R-002). It reuses gatherPushSessions and finalizePush, so the
+// assembly cannot drift from a push and a repeated `git push` does not re-store
+// (D-001).
+//
+// It returns the number of sessions stored and the directory they were written
+// under (resolved even when nothing was stored, so the caller can report it).
+func RunTraceLocalStore(ctx context.Context, log zerolog.Logger, opts RunTraceLocalStoreOpts) (stored int, tracePath string, err error) {
+ store, repoRoot, err := state.Locate()
+ if err != nil {
+ return 0, "", err
+ }
+
+ tracePath = ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(repoRoot))
+ sessionMode := aicodingsession.ResolveMode(opts.Mode)
+
+ log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot).
+ Str("trace_path", tracePath).Bool("no_redact", opts.NoRedact).Str("mode", sessionMode).
+ Bool("allow_empty", opts.AllowEmpty).Msg("trace local store invoked")
+
+ sessions, aiCommits, err := gatherPushSessions(ctx, store, repoRoot, sessionMode, opts.AllowEmpty, log)
+ if err != nil {
+ return 0, tracePath, err
+ }
+ if len(sessions) == 0 {
+ return 0, tracePath, nil
+ }
+
+ stored, err = storeSessionsLocally(ctx, log, store, sessions, tracePath, opts.NoRedact)
+ if err != nil {
+ return stored, tracePath, err
+ }
+
+ // Prune the single-use state only when every gathered session was stored.
+ // finalizePush marks all of aiCommits tracked and wipes their retry state,
+ // so finalizing while one session failed to store would strand that
+ // session's evidence with no way back. A run that stored nothing, or only
+ // some sessions, leaves the recorded sessions in place for a later push to
+ // retry. Re-storing a session that already succeeded is harmless: the store
+ // is content-addressed and rewrites the same bytes.
+ if stored == len(sessions) && stored > 0 {
+ finalizePush(store, repoRoot, aiCommits, log)
+ }
+
+ return stored, tracePath, nil
+}
+
+// ResolveTracePath returns where LOCAL trace storage writes its evidence sets:
+// the persisted tracePath when set (absolute, or relative to the repository
+// root), otherwise the per-clone default under the trace state directory.
+func ResolveTracePath(repoRoot string, store *state.Store, persisted string) string {
+ switch {
+ case persisted == "":
+ return store.LocalSessionsDir()
+ case filepath.IsAbs(persisted):
+ return persisted
+ default:
+ return filepath.Join(repoRoot, persisted)
+ }
+}
+
+// storeSessionsLocally writes each assembled session under tracePath in its own
+// content-addressed directory: tracePath//manifest.json plus a
+// materials/ folder whose files are named by digest. Each session is
+// self-contained, so it uses a fresh sink and runs the shared assembly for that
+// one session. It returns how many sessions were stored.
+func storeSessionsLocally(ctx context.Context, log zerolog.Logger, store *state.Store, sessions []sessionEvidence, tracePath string, noRedact bool) (int, error) {
+ if err := os.MkdirAll(tracePath, 0o700); err != nil {
+ return 0, fmt.Errorf("create trace path: %w", err)
+ }
+
+ stored := 0
+ for _, se := range sessions {
+ sessionDir := filepath.Join(tracePath, state.SanitizeID(se.sessionID))
+ sink := newDiskMaterialSink(sessionDir, noRedact, log)
+
+ // A single-session slice keeps each export self-contained: the assembly
+ // already uses a fresh name allocator and redactor per call, so nothing
+ // crosses between sessions.
+ attested, _ := attachSessionEvidence(ctx, sink, store, []sessionEvidence{se}, log, noRedact)
+ if len(attested) == 0 {
+ log.Warn().Str("session", se.sessionID).Msg("no evidence stored for this session")
+ continue
+ }
+
+ if err := sink.writeManifest(!noRedact); err != nil {
+ return stored, fmt.Errorf("write manifest for session %s: %w", se.sessionID, err)
+ }
+
+ log.Debug().Str("session", se.sessionID).Str("dir", sessionDir).
+ Int("materials", len(sink.materials)).Msg("session evidence stored")
+ stored++
+ }
+
+ return stored, nil
+}
+
+// diskMaterialSink implements specMaterialAdder by crafting each material
+// offline and writing its content under its content digest. It is the local
+// 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 stored 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 []storedMaterial
+}
+
+// storedMaterial is one material's entry in the manifest.
+type storedMaterial 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 session
+ // directory.
+ File string `json:"file"`
+ // Annotations are the attestation annotations the material carries.
+ Annotations map[string]string `json:"annotations,omitempty"`
+}
+
+// localManifest is the index of one stored session.
+type localManifest struct {
+ GeneratedAt string `json:"generated_at"`
+ // Signed and IsAttestation are always false: the stored evidence 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
+ // stored evidence for an attestation (R-006).
+ Signed bool `json:"signed"`
+ IsAttestation bool `json:"is_attestation"`
+ // Redaction records whether secrets were stripped, so a reader can tell a
+ // safe-to-share session from a raw one.
+ Redaction bool `json:"redaction"`
+ // EvidenceRecord names the CHAINLOOP_AI_CODING_SESSION material that holds
+ // the session evidence record (and the warnings of the run).
+ EvidenceRecord string `json:"evidence_record,omitempty"`
+ // Materials lists every stored material by name, kind and digest, so a
+ // reader sees the whole set from the index with no need to parse each file.
+ Materials []storedMaterial `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-local", 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 a 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 store", 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, storedMaterial{
+ 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 session 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 index of one stored session. redaction records
+// whether secrets were stripped.
+func (s *diskMaterialSink) writeManifest(redaction bool) error {
+ sessionKind := v1.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION.String()
+
+ var evidenceRecord string
+ for _, m := range s.materials {
+ if m.Kind == sessionKind {
+ evidenceRecord = m.Name
+ break
+ }
+ }
+
+ manifest := localManifest{
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ Signed: false,
+ IsAttestation: false,
+ Redaction: redaction,
+ EvidenceRecord: evidenceRecord,
+ Materials: s.materials,
+ }
+
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode manifest: %w", err)
+ }
+
+ if err := os.MkdirAll(s.outDir, 0o700); err != nil {
+ return fmt.Errorf("create session directory: %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
+ }
+
+ // Both maps hold a handful of annotation keys, so a capacity hint from
+ // crafted alone is enough and the map grows for the few extra entries.
+ // Summing the two lengths is an overflow pattern a scanner flags for no gain.
+ merged := make(map[string]string, len(crafted))
+ 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_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go
new file mode 100644
index 000000000..975780767
--- /dev/null
+++ b/app/cli/pkg/action/trace_local_store_test.go
@@ -0,0 +1,216 @@
+//
+// 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"
+ "os"
+ "path/filepath"
+ "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"
+)
+
+// TestStoreSessionsLocally follows R-002/R-003/R-004/R-006: the same assembly a
+// push runs is written to disk, content-addressed, redacted by default, with a
+// manifest that declares the output is unsigned and not an attestation.
+func TestStoreSessionsLocally(t *testing.T) {
+ const sessionID = "7c1e2d-session"
+ // Assembled from fragments so nothing credential-shaped is committed.
+ const pat = "ghp_erOZlZv0B1e3amrQ" + "ugdwZ8Ro2W4kDql9WPTf"
+ const ticketText = "---\nkind: ticket\nuri: https://tracker.example.com/ENG-1234\n---\n\nAdd the export button.\nToken: " + pat + "\n"
+ ticket := specCapture(t, "ticket-eng-1234.md", ticketText, "2026-10-08T10:00:00Z")
+
+ // A fresh session each run: attachSessionEvidence mutates the evidence
+ // (spec entries, pointerized transcript), so the two runs must not share it.
+ newSession := func() sessionEvidence {
+ evidence := aicodingsession.NewEvidence(aicodingsession.Data{
+ SchemaVersion: "v1",
+ Agent: aicodingsession.Agent{Name: "claude"},
+ Session: aicodingsession.Session{ID: sessionID, StartedAt: "2026-10-08T10:00:00Z", DurationSeconds: 100},
+ RawSession: map[string][]json.RawMessage{mainStream: {json.RawMessage(`{"type":"user","message":{"role":"user","content":"hi"}}`)}},
+ })
+ return sessionEvidence{
+ sessionID: sessionID,
+ provider: claude.New(),
+ evidence: evidence,
+ specs: []spec.Capture{ticket},
+ }
+ }
+
+ ctx := context.Background()
+
+ // A push against the fake adder is the reference for the EVIDENCE (spec)
+ // material digests: it and the disk sink receive the identical redacted spec
+ // copy, so the digests must match (R-002).
+ pushAdder := &fakeMaterialAdder{realDigests: true}
+ pushAttested, _ := attachSessionEvidence(ctx, pushAdder, state.NewGitStore(t.TempDir()), []sessionEvidence{newSession()}, zerolog.Nop(), false)
+ require.Equal(t, []string{sessionID}, pushAttested)
+
+ t.Run("content-addressed, redacted, unsigned session on disk", func(t *testing.T) {
+ tracePath := t.TempDir()
+ stored, err := storeSessionsLocally(ctx, zerolog.Nop(), state.NewGitStore(t.TempDir()), []sessionEvidence{newSession()}, tracePath, false)
+ require.NoError(t, err)
+ require.Equal(t, 1, stored)
+
+ sessionDir := filepath.Join(tracePath, state.SanitizeID(sessionID))
+ var m localManifest
+ readJSONFile(t, filepath.Join(sessionDir, manifestFileName), &m)
+
+ // R-006: the index declares the stored evidence is not a signed attestation.
+ assert.False(t, m.Signed)
+ assert.False(t, m.IsAttestation)
+ assert.True(t, m.Redaction)
+ assert.Equal(t, "ai-coding-session-7c1e2d", m.EvidenceRecord)
+ require.NotEmpty(t, m.Materials)
+
+ var specDigest string
+ for _, mat := range m.Materials {
+ content, err := os.ReadFile(filepath.Join(sessionDir, mat.File))
+ require.NoError(t, err)
+
+ // R-003: every material is stored under its content digest.
+ sum := sha256.Sum256(content)
+ assert.Equal(t, "sha256:"+hex.EncodeToString(sum[:]), mat.Digest, "the stored file hashes back to its digest for %s", mat.Name)
+
+ // R-004: redaction on by default, so no stored file holds the secret.
+ assert.NotContains(t, string(content), pat, "no stored file holds the secret: %s", mat.Name)
+
+ if mat.Kind == "EVIDENCE" {
+ specDigest = mat.Digest
+ // R-002: the spec material digest equals what a push produces.
+ assert.Equal(t, pushAdder.byName(mat.Name).digest, mat.Digest, "same digest as a push for %s", mat.Name)
+ }
+ }
+ require.NotEmpty(t, specDigest, "the ticket became an EVIDENCE material")
+
+ // The evidence record references the stored spec material by digest.
+ record := materialByName(t, m, m.EvidenceRecord)
+ var ev aicodingsession.Evidence
+ readJSONFile(t, filepath.Join(sessionDir, record.File), &ev)
+ referenced := make([]string, 0, len(ev.Data.Spec))
+ for _, e := range ev.Data.Spec {
+ referenced = append(referenced, e.Digest)
+ }
+ assert.Contains(t, referenced, specDigest, "the evidence record points at the stored spec material")
+ })
+
+ t.Run("the no-redact opt-out keeps the raw secret", func(t *testing.T) {
+ tracePath := t.TempDir()
+ stored, err := storeSessionsLocally(ctx, zerolog.Nop(), state.NewGitStore(t.TempDir()), []sessionEvidence{newSession()}, tracePath, true)
+ require.NoError(t, err)
+ require.Equal(t, 1, stored)
+
+ sessionDir := filepath.Join(tracePath, state.SanitizeID(sessionID))
+ var m localManifest
+ readJSONFile(t, filepath.Join(sessionDir, manifestFileName), &m)
+ assert.False(t, m.Redaction, "the manifest records that redaction was off")
+
+ var found bool
+ for _, mat := range m.Materials {
+ content, err := os.ReadFile(filepath.Join(sessionDir, mat.File))
+ require.NoError(t, err)
+ if strings.Contains(string(content), pat) {
+ found = true
+ }
+ }
+ assert.True(t, found, "the opt-out keeps the raw secret in the output")
+ })
+}
+
+// TestRunTraceLocalStoreNoSessions covers the local pre-push path when nothing
+// was recorded: it stores nothing, returns the resolved default path, and
+// reaches no control plane (it never builds an executor), so a human-only push
+// is never blocked (decision 4) and needs no network (R-005).
+func TestRunTraceLocalStoreNoSessions(t *testing.T) {
+ _, gitDir := initGitRepo(t)
+ require.NoError(t, state.NewGitStore(gitDir).InitTraceDir())
+
+ stored, tracePath, err := RunTraceLocalStore(t.Context(), zerolog.Nop(), RunTraceLocalStoreOpts{Mode: aicodingsession.ModeCoding})
+ require.NoError(t, err)
+ assert.Zero(t, stored, "nothing recorded, nothing stored")
+ assert.Equal(t, state.NewGitStore(gitDir).LocalSessionsDir(), tracePath, "the default path is resolved and returned")
+
+ _, statErr := os.Stat(tracePath)
+ assert.True(t, os.IsNotExist(statErr), "no output directory is created when there is nothing to store")
+}
+
+// TestResolveTracePath covers the path resolver: a persisted absolute or
+// repo-relative path wins, and an empty one falls back to the store default.
+func TestResolveTracePath(t *testing.T) {
+ store := state.NewGitStore(filepath.Join(t.TempDir(), ".git"))
+ repoRoot := "/repo"
+
+ assert.Equal(t, store.LocalSessionsDir(), ResolveTracePath(repoRoot, store, ""))
+ assert.Equal(t, "/abs/sessions", ResolveTracePath(repoRoot, store, "/abs/sessions"))
+ assert.Equal(t, filepath.Join(repoRoot, "evidence"), ResolveTracePath(repoRoot, store, "evidence"))
+}
+
+func TestLocalSessionStartBanner(t *testing.T) {
+ t.Run("names the on-disk destination and the push timing", func(t *testing.T) {
+ got := localSessionStartBanner("/repo/.git/chainloop-trace/local-sessions", false)
+ assert.Contains(t, got, "recording this session")
+ assert.Contains(t, got, "local mode")
+ assert.Contains(t, got, "/repo/.git/chainloop-trace/local-sessions")
+ assert.Contains(t, got, "when you push")
+ // No Chainloop/control-plane vocabulary: a reader should not think a
+ // backend is involved.
+ assert.NotContains(t, got, "attest")
+ assert.NotContains(t, got, "dashboard")
+ })
+
+ t.Run("says this run finishes under trace run", func(t *testing.T) {
+ got := localSessionStartBanner("/repo/.git/chainloop-trace/local-sessions", true)
+ assert.Contains(t, got, "local mode")
+ assert.Contains(t, got, "when this run finishes")
+ assert.NotContains(t, got, "when you push")
+ })
+
+ t.Run("drops the destination when unknown", func(t *testing.T) {
+ got := localSessionStartBanner("", false)
+ assert.Contains(t, got, "recording this session")
+ assert.Contains(t, got, "local mode")
+ assert.NotContains(t, got, "stored on disk at")
+ })
+}
+
+func readJSONFile(t *testing.T, path string, v any) {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ require.NoError(t, json.Unmarshal(data, v))
+}
+
+func materialByName(t *testing.T, m localManifest, name string) storedMaterial {
+ t.Helper()
+ for _, mat := range m.Materials {
+ if mat.Name == name {
+ return mat
+ }
+ }
+ require.Failf(t, "material not found", "material %q not found in manifest", name)
+ return storedMaterial{}
+}
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..5d9c71eda 100644
--- a/app/cli/pkg/action/trace_run.go
+++ b/app/cli/pkg/action/trace_run.go
@@ -24,6 +24,7 @@ import (
"path/filepath"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
+ "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/spec"
@@ -76,11 +77,17 @@ type TraceRunOpts struct {
ContractRequired bool
// ActionOpts is the root command's initialized options, used to build
- // the attestation executor. Required.
+ // the attestation executor. Required unless Local, which reaches no
+ // control plane.
ActionOpts *ActionsOpts
// CLIVersion is the bare CLI version recorded in the attestation
// predicate.
CLIVersion string
+
+ // Local runs in local storage mode (traceStorage: LOCAL): the session is
+ // written to disk at session end instead of attested, and no control plane
+ // is contacted. The identity fields above are then unused.
+ Local bool
}
// TraceRun wraps a single-shot agent invocation: it cleans any prior
@@ -98,20 +105,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
+ // Local mode reaches 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.Local {
+ 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 +191,46 @@ 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()
+
+ // Local mode writes the evidence to disk whatever the agent's exit status
+ // was: a session the user interrupted still has evidence worth keeping, and
+ // it reaches no control plane. The attestation path, in contrast, attests
+ // only a clean run. Either way the agent's own exit code is preserved.
+ if opts.Local {
+ log.Debug().Msg("wrapped command finished; storing session evidence to disk")
+
+ stored, tracePath, storeErr := RunTraceLocalStore(ctx, log, RunTraceLocalStoreOpts{
+ NoRedact: traceNoRedactFromEnv(),
+ Mode: aicodingsession.ModeGeneric,
+ AllowEmpty: true,
+ })
+ switch {
+ case storeErr != nil:
+ log.Warn().Err(storeErr).Msg("could not store session evidence")
+ case stored == 0:
+ log.Warn().Msg("no AI coding session was recorded, so nothing was stored")
+ default:
+ log.Info().Int("sessions", stored).Msgf("AI coding session evidence stored at %s", tracePath)
+ }
+
+ // The agent's own exit status wins: a non-zero exit is what the user
+ // needs to see, and a store failure must not mask it. Only when the
+ // agent exited cleanly does a store failure become the error to return,
+ // so a silent failure to persist the evidence is never reported as
+ // success.
+ if runErr != nil {
+ return subprocessError(opts.Command[0], runErr)
+ }
+ if storeErr != nil {
+ return fmt.Errorf("store session evidence locally: %w", storeErr)
}
- return fmt.Errorf("run %s: %w", opts.Command[0], err)
+ return nil
+ }
+
+ if runErr != nil {
+ return subprocessError(opts.Command[0], runErr)
}
log.Debug().Msg("wrapped command completed; attesting session")
@@ -204,6 +249,23 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error
})
}
+// 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)
+}
+
// prepareTraceRunWorkflow checks the credentials and creates the workflow the
// session will be attested to, so it gets the trace contract attached instead
// of the empty one the control plane creates implicitly at push time.
@@ -353,7 +415,11 @@ func CleanupTrace(store *state.Store, repoRoot string, log zerolog.Logger) error
}
}
- if err := store.RemoveTraceDir(); err != nil {
+ // Preserve the configured storage path too: a custom tracePath can resolve
+ // inside the trace directory, and RemoveTraceDir would otherwise delete the
+ // stored evidence it is not named local-sessions.
+ storagePath := ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(repoRoot))
+ if err := store.RemoveTraceDir(storagePath); err != nil {
log.Warn().Err(err).Msg("could not remove trace directory")
errs = append(errs, fmt.Errorf("remove trace directory: %w", err))
}
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..48e2ce234 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 store, whose output can then hold secrets
+ // (R-004). It is off for a push, which always redacts.
+ 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")