From 49b0b6c2a802b280cf104fee542611dd3a9949c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 14 Oct 2026 01:47:02 -0300 Subject: [PATCH 01/19] feat(trace): add local storage mode that records sessions without a control plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chainloop Trace records an AI coding session and, on git push, uploads a signed attestation to a control plane. This adds a local mode where a repository records its sessions and stores the assembled evidence set on disk, with no control plane, login, or network. The storage mode is a committed repository policy in .chainloop.yml: traceStorage (CONTROL_PLANE default, or LOCAL), an optional tracePath, and traceRequired (replacing the deprecated requireTrace). `trace init --local` sets a repository up with no control plane, and the pre-push hook then runs the same push-time assembly and writes each material under its content digest, with a per-session manifest that declares the output unsigned and not an attestation. `trace run` honors the mode as well. The stored evidence equals what a push would upload apart from the signature, and secret redaction stays on by default with a CHAINLOOP_TRACE_NO_REDACT opt-out for a trusted local store. In local mode no control-plane connection is opened: the trace commands build it lazily and only on the attestation path. The stored sessions are kept across a trace uninstall. Refs #3582 Chainloop-Trace-Sessions: 7492b77a-1dc6-4352-823f-f87c42672452 Signed-off-by: Matías Insaurralde --- app/cli/cmd/root.go | 185 +++++---- app/cli/cmd/trace_hook.go | 25 +- app/cli/cmd/trace_init.go | 174 +++++++- app/cli/cmd/trace_init_local_test.go | 134 +++++++ app/cli/cmd/trace_run.go | 85 ++-- app/cli/documentation/cli-reference.md | 43 +- app/cli/internal/repositoryconfig/config.go | 17 +- app/cli/internal/trace/README.md | 5 +- app/cli/internal/trace/config/config.go | 97 +++++ app/cli/internal/trace/config/storage_test.go | 145 +++++++ app/cli/internal/trace/state/state.go | 48 ++- app/cli/internal/trace/state/state_test.go | 35 ++ app/cli/pkg/action/trace_agent_hook.go | 38 +- app/cli/pkg/action/trace_hook_handler.go | 245 +++++++----- app/cli/pkg/action/trace_local_store.go | 370 ++++++++++++++++++ app/cli/pkg/action/trace_local_store_test.go | 206 ++++++++++ .../pkg/action/trace_pasted_images_test.go | 4 +- app/cli/pkg/action/trace_run.go | 90 ++++- .../pkg/action/trace_skills_scenario_test.go | 2 +- app/cli/pkg/action/trace_skills_test.go | 12 +- app/cli/pkg/action/trace_spec_materials.go | 19 +- .../pkg/action/trace_spec_materials_test.go | 24 +- .../pkg/action/trace_spec_pointers_test.go | 4 +- 23 files changed, 1710 insertions(+), 297 deletions(-) create mode 100644 app/cli/cmd/trace_init_local_test.go create mode 100644 app/cli/internal/trace/config/storage_test.go create mode 100644 app/cli/pkg/action/trace_local_store.go create mode 100644 app/cli/pkg/action/trace_local_store_test.go diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 6067f6e75..e567734bc 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -147,6 +147,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 +270,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/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..113f22b28 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,62 @@ 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.saveTraceStorage = localFlag + cfg.saveTracePath = localFlag && tracePathFlag != "" + + // 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 +150,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 +235,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 +258,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 +333,57 @@ 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 is what makes the choice shared: traceStorage: LOCAL is a +// repository policy, so a teammate who commits and clones it records locally +// too. 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 every clone records locally: + 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 + +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 +570,16 @@ 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 + // 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 } // resolveTraceInitConfig reads the trace settings from the flags, falling back @@ -524,6 +637,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, config.TraceStorageLocal); err != nil { + return err + } + logger.Debug().Msg("traceStorage: LOCAL 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 +675,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..e456b82d2 --- /dev/null +++ b/app/cli/cmd/trace_init_local_test.go @@ -0,0 +1,134 @@ +// +// 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/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) + } + }) +} + +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) +} diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 998351966..083d364f7 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -31,11 +31,14 @@ 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 every hook and local trace artifact when the wrapped command exits, so a +session never leaks setup into the next one. The workflow is created before the command runs. That part is best-effort: the session runs either way and the attestation creates the workflow if this did @@ -58,6 +61,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 +95,51 @@ 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 { + // --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 +154,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..31e5195c9 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,14 @@ 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 every hook and local trace artifact when the wrapped command exits, so a +session never leaks setup into the next one. The workflow is created before the command runs. That part is best-effort: the session runs either way and the attestation creates the workflow if this did @@ -3388,9 +3401,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..e158c337e 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -77,10 +77,107 @@ 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. +func LoadTraceStorageFromYML(dir string) (string, error) { + cfg := repositoryconfig.FindChainloopYML(dir) + if cfg == nil { + return TraceStorageControlPlane, nil + } + + 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..f9cb0d255 --- /dev/null +++ b/app/cli/internal/trace/config/storage_test.go @@ -0,0 +1,145 @@ +// +// 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)) + }) +} + +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..fd1242306 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,40 @@ func (s *Store) staleSessionIDs(unattested map[string]struct{}) map[string]struc return stale } -// RemoveTraceDir removes the entire /chainloop-trace/ directory. +// RemoveTraceDir removes the trace state but preserves local-sessions/, 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. 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() error { - return os.RemoveAll(s.traceDirPath()) + base := s.traceDirPath() + + entries, err := os.ReadDir(base) + if err != nil { + if os.IsNotExist(err) { + return nil + } + + return err + } + + for _, e := range entries { + if e.Name() == traceDirLocalSessions { + 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 } // 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..13e8918ad 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -109,6 +109,41 @@ 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)) + }) +} + 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..980a25f28 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -150,6 +150,19 @@ func sessionStartBanner(dashboardURL, org, project string) string { return banner } +// localSessionStartBanner is the session-start banner for a local-mode +// repository. It says the session is recorded and where its evidence lands, +// with no Chainloop vocabulary and no control-plane round trip: local mode has +// no dashboard to point at (R-012). +func localSessionStartBanner(tracePath string) string { + banner := "Chainloop Trace is recording this session." + if tracePath != "" { + banner += "\nIts evidence will be stored on disk at " + tracePath + " when you push." + } + + 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 +199,18 @@ 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))) + } else { + banner = sessionStartBanner( + hookDashboardURL(log), + config.LoadOrganizationFromYML(repoRoot), + repositoryconfig.LoadProjectFromYML(repoRoot), + ) + } msg.Banner = "\n\n" + banner + "\n" } @@ -752,8 +772,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..67d7d456c --- /dev/null +++ b/app/cli/pkg/action/trace_local_store.go @@ -0,0 +1,370 @@ +// +// 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 + } + + // Only prune the single-use state once something was stored, so a run that + // stored nothing leaves the recorded session in place for a later retry. + if 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 + } + + merged := make(map[string]string, len(crafted)+len(extra)) + for k, v := range crafted { + merged[k] = v + } + for k, v := range extra { + merged[k] = v + } + + return merged +} diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go new file mode 100644 index 000000000..bac87e795 --- /dev/null +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -0,0 +1,206 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "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", func(t *testing.T) { + got := localSessionStartBanner("/repo/.git/chainloop-trace/local-sessions") + assert.Contains(t, got, "recording this session") + assert.Contains(t, got, "/repo/.git/chainloop-trace/local-sessions") + // 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("drops the destination when unknown", func(t *testing.T) { + got := localSessionStartBanner("") + assert.Contains(t, got, "recording this session") + 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..3c1cdcf06 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -76,11 +76,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 +104,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 +190,35 @@ 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: + // A store failure must not mask the agent's own exit status. + 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) } - return fmt.Errorf("run %s: %w", opts.Command[0], err) + return subprocessError(opts.Command[0], runErr) + } + + if runErr != nil { + return subprocessError(opts.Command[0], runErr) } log.Debug().Msg("wrapped command completed; attesting session") @@ -204,6 +237,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. 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") From 23b1dfee4daeffce4e9386a3838a28c4e715820c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 14 Oct 2026 13:46:11 -0300 Subject: [PATCH 02/19] fix(trace): size the merged-annotations map from one length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local store sized the merged annotations map from len(crafted)+len(extra), which a static analyzer flags as an allocation size that may overflow. Both maps hold only a handful of annotation keys, so the sum cannot overflow in practice; size the hint from one length and let the map grow instead. Refs #3582 Signed-off-by: Matías Insaurralde --- app/cli/pkg/action/trace_local_store.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index 67d7d456c..82b12f51b 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -358,7 +358,10 @@ func mergeAnnotations(crafted, extra map[string]string) map[string]string { return nil } - merged := make(map[string]string, len(crafted)+len(extra)) + // 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 } From cefc946edc5c96364cdb3dd7591e9fb3a5b41e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 14 Oct 2026 15:05:03 -0300 Subject: [PATCH 03/19] feat(trace): name local mode in the session-start banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local-mode session-start banner now says the session is recorded in local mode, so a developer can tell at a glance which storage mode is active without inferring it from the destination line (R-012). Signed-off-by: Matías Insaurralde --- app/cli/pkg/action/trace_agent_hook.go | 8 ++++---- app/cli/pkg/action/trace_local_store_test.go | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 980a25f28..c3263aef4 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -151,11 +151,11 @@ func sessionStartBanner(dashboardURL, org, project string) string { } // localSessionStartBanner is the session-start banner for a local-mode -// repository. It says the session is recorded and where its evidence lands, -// with no Chainloop vocabulary and no control-plane round trip: local mode has -// no dashboard to point at (R-012). +// 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) string { - banner := "Chainloop Trace is recording this session." + banner := "Chainloop Trace is recording this session in local mode." if tracePath != "" { banner += "\nIts evidence will be stored on disk at " + tracePath + " when you push." } diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index bac87e795..a2dfe8316 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -173,6 +173,7 @@ func TestLocalSessionStartBanner(t *testing.T) { t.Run("names the on-disk destination", func(t *testing.T) { got := localSessionStartBanner("/repo/.git/chainloop-trace/local-sessions") assert.Contains(t, got, "recording this session") + assert.Contains(t, got, "local mode") assert.Contains(t, got, "/repo/.git/chainloop-trace/local-sessions") // No Chainloop/control-plane vocabulary: a reader should not think a // backend is involved. @@ -183,6 +184,7 @@ func TestLocalSessionStartBanner(t *testing.T) { t.Run("drops the destination when unknown", func(t *testing.T) { got := localSessionStartBanner("") assert.Contains(t, got, "recording this session") + assert.Contains(t, got, "local mode") assert.NotContains(t, got, "stored on disk at") }) } From f05dd48ea5e9550f407f07d0a2f28c13088e3024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 14 Oct 2026 15:39:06 -0300 Subject: [PATCH 04/19] fix(trace): address review feedback on local storage mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A config read or parse error no longer selects CONTROL_PLANE silently, so a LOCAL repository with a broken .chainloop.yml cannot have its pre-push hook upload a local-only session. Local evidence is finalized only when every gathered session is stored, so a partial failure no longer strands a session's retry state. A control-plane trace run is reported in telemetry again, while a local run stays excluded. A trace run that stores its evidence surfaces a store failure when the wrapped command succeeded. Uninstall preserves a configured tracePath that resolves inside the trace directory. The local session-start banner states the storage timing that matches how the session is driven. Signed-off-by: Matías Insaurralde --- app/cli/cmd/root.go | 7 ++- app/cli/cmd/telemetry_report.go | 9 ++- app/cli/cmd/telemetry_report_test.go | 7 +++ app/cli/cmd/trace_run.go | 10 +++- app/cli/documentation/cli-reference.md | 5 +- app/cli/internal/trace/config/config.go | 13 ++++- app/cli/internal/trace/config/storage_test.go | 13 +++++ app/cli/internal/trace/state/state.go | 58 ++++++++++++++++--- app/cli/internal/trace/state/state_test.go | 25 ++++++++ app/cli/pkg/action/trace_agent_hook.go | 16 ++++- app/cli/pkg/action/trace_local_store.go | 11 +++- app/cli/pkg/action/trace_local_store_test.go | 14 ++++- app/cli/pkg/action/trace_run.go | 22 ++++++- 13 files changed, 182 insertions(+), 28 deletions(-) diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index e567734bc..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/ 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_run.go b/app/cli/cmd/trace_run.go index 083d364f7..ef349ecfb 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -37,8 +37,9 @@ 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 every hook and local trace artifact when the wrapped command exits, so a -session never leaks setup into the next one. +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 @@ -111,6 +112,11 @@ func newTraceRunCmd() *cobra.Command { ) 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") diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 31e5195c9..7f31d908c 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -3382,8 +3382,9 @@ 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 every hook and local trace artifact when the wrapped command exits, so a -session never leaks setup into the next one. +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 diff --git a/app/cli/internal/trace/config/config.go b/app/cli/internal/trace/config/config.go index e158c337e..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" @@ -110,12 +111,18 @@ func ResolveTraceStorage(persisted string) (string, error) { } // LoadTraceStorageFromYML reads and canonicalizes the traceStorage mode from the -// nearest .chainloop.yml. A missing config or field is CONTROL_PLANE. +// 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 := repositoryconfig.FindChainloopYML(dir) - if cfg == nil { + cfg, _, err := repositoryconfig.LoadChainloopYML(dir) + if errors.Is(err, repositoryconfig.ErrChainloopYMLNotFound) { return TraceStorageControlPlane, nil } + if err != nil { + return "", err + } return ResolveTraceStorage(cfg.TraceStorage) } diff --git a/app/cli/internal/trace/config/storage_test.go b/app/cli/internal/trace/config/storage_test.go index f9cb0d255..db26679e8 100644 --- a/app/cli/internal/trace/config/storage_test.go +++ b/app/cli/internal/trace/config/storage_test.go @@ -82,6 +82,19 @@ func TestLoadTraceStorageFromYML(t *testing.T) { // 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) { diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go index fd1242306..93ba9c17e 100644 --- a/app/cli/internal/trace/state/state.go +++ b/app/cli/internal/trace/state/state.go @@ -347,15 +347,25 @@ func (s *Store) staleSessionIDs(unattested map[string]struct{}) map[string]struc return stale } -// RemoveTraceDir removes the trace state but preserves local-sessions/, 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. An ordinary repository with no -// stored sessions is left as clean as before: the trace directory is removed +// 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() error { +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) { @@ -366,7 +376,7 @@ func (s *Store) RemoveTraceDir() error { } for _, e := range entries { - if e.Name() == traceDirLocalSessions { + if _, ok := keep[e.Name()]; ok { continue } if err := os.RemoveAll(filepath.Join(base, e.Name())); err != nil { @@ -383,6 +393,40 @@ func (s *Store) RemoveTraceDir() error { 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. func (s *Store) LogFilePath() string { return filepath.Join(s.traceDirPath(), "log.txt") diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index 13e8918ad..e22c9f2bb 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -142,6 +142,31 @@ func TestRemoveTraceDirPreservesLocalSessions(t *testing.T) { _, 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) { diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index c3263aef4..25cd6ac18 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -154,10 +154,17 @@ func sessionStartBanner(dashboardURL, org, project string) string { // 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) string { +func localSessionStartBanner(tracePath string, traceRunActive bool) string { banner := "Chainloop Trace is recording this session in local mode." if tracePath != "" { - banner += "\nIts evidence will be stored on disk at " + tracePath + " when you push." + // 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 @@ -203,7 +210,10 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error 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))) + banner = localSessionStartBanner( + ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(repoRoot)), + store.IsTraceRunActive(), + ) } else { banner = sessionStartBanner( hookDashboardURL(log), diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index 82b12f51b..c471f94d2 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -109,9 +109,14 @@ func RunTraceLocalStore(ctx context.Context, log zerolog.Logger, opts RunTraceLo return stored, tracePath, err } - // Only prune the single-use state once something was stored, so a run that - // stored nothing leaves the recorded session in place for a later retry. - if stored > 0 { + // 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) } diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index a2dfe8316..975780767 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -170,19 +170,27 @@ func TestResolveTracePath(t *testing.T) { } func TestLocalSessionStartBanner(t *testing.T) { - t.Run("names the on-disk destination", func(t *testing.T) { - got := localSessionStartBanner("/repo/.git/chainloop-trace/local-sessions") + 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("") + got := localSessionStartBanner("", false) assert.Contains(t, got, "recording this session") assert.Contains(t, got, "local mode") assert.NotContains(t, got, "stored on disk at") diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index 3c1cdcf06..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" @@ -206,7 +207,6 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error }) switch { case storeErr != nil: - // A store failure must not mask the agent's own exit status. 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") @@ -214,7 +214,19 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error log.Info().Int("sessions", stored).Msgf("AI coding session evidence stored at %s", tracePath) } - return subprocessError(opts.Command[0], runErr) + // 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 nil } if runErr != nil { @@ -403,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)) } From fbb738e820fdd670845bd8734e16325e4d1dad56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 14 Oct 2026 15:56:59 -0300 Subject: [PATCH 05/19] fix(trace): clear stale local mode on control-plane init and correct clone guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control-plane trace init over a repository previously set to LOCAL now writes traceStorage: CONTROL_PLANE, so the pre-push hook stops storing on disk and the init no longer disagrees with how the repository behaves. The local-mode next steps now tell each teammate to run chainloop trace init --local after cloning, since the managed git hooks live under .git and are not cloned, so a commit alone does not set a clone up to record. Signed-off-by: Matías Insaurralde --- app/cli/cmd/trace_init.go | 50 ++++++++++++++++++++---- app/cli/cmd/trace_init_local_test.go | 58 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 113f22b28..3e5c40caa 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -116,9 +116,16 @@ with --local; --require-trace can.`, } cfg.local = localFlag cfg.tracePath = tracePathFlag - cfg.saveTraceStorage = localFlag 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, _ := config.LoadTraceStorageFromYML(repoRoot) + 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. @@ -350,10 +357,12 @@ func writeTraceInitLocalSummary(w io.Writer, repoRoot string, harnesses []string } // writeTraceLocalNextSteps says what is left after a local init. Committing -// .chainloop.yml is what makes the choice shared: traceStorage: LOCAL is a -// repository policy, so a teammate who commits and clones it records locally -// too. 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. +// .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) { @@ -374,12 +383,16 @@ func writeTraceLocalNextSteps(w io.Writer, repoRoot, workDir string, installed [ fmt.Fprintf(w, ` What's next - 1. Commit these files so every clone records locally: + 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) } @@ -575,6 +588,10 @@ type traceInitConfig struct { // 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. @@ -582,6 +599,23 @@ type traceInitConfig struct { 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 // to the values already in .chainloop.yml. func resolveTraceInitConfig(cmd *cobra.Command, repoRoot, projectFlag string) (*traceInitConfig, error) { @@ -640,10 +674,10 @@ 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, config.TraceStorageLocal); err != nil { + if err := config.SaveTraceStorageToYML(repoRoot, c.traceStorage); err != nil { return err } - logger.Debug().Msg("traceStorage: LOCAL saved to .chainloop.yml") + logger.Debug().Str("traceStorage", c.traceStorage).Msg("traceStorage saved to .chainloop.yml") } if c.saveTracePath { diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go index e456b82d2..17480d249 100644 --- a/app/cli/cmd/trace_init_local_test.go +++ b/app/cli/cmd/trace_init_local_test.go @@ -22,6 +22,7 @@ import ( "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" @@ -131,4 +132,61 @@ func TestWriteTraceLocalNextSteps(t *testing.T) { 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) + }) + } } From e7e4e2476a6ec2273603f09dfad4f86894a63d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 14 Oct 2026 16:04:46 -0300 Subject: [PATCH 06/19] fix(trace): fail trace init on an unreadable traceStorage value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control-plane trace init now surfaces an invalid traceStorage in an existing .chainloop.yml before installing hooks, instead of discarding the error and leaving a repository whose every pre-push fails on the same config. An explicit --local run still overrides the bad value. Signed-off-by: Matías Insaurralde --- app/cli/cmd/trace_init.go | 10 +++++++- app/cli/cmd/trace_init_local_test.go | 34 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 3e5c40caa..eae02fa1c 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -123,7 +123,15 @@ with --local; --require-trace can.`, // 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, _ := config.LoadTraceStorageFromYML(repoRoot) + 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, diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go index 17480d249..067ae5d70 100644 --- a/app/cli/cmd/trace_init_local_test.go +++ b/app/cli/cmd/trace_init_local_test.go @@ -111,6 +111,40 @@ func TestTraceInitLocal(t *testing.T) { }) } +// 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 From 95c135315026b41dd5babb653a3f59bdd93891e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 14 Oct 2026 16:20:18 -0300 Subject: [PATCH 07/19] feat(trace): announce the switch back to control-plane mode and document it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control-plane trace init that clears a previous LOCAL policy now tells the user the repository switched and to commit .chainloop.yml, instead of changing the mode with only a debug log. The trace README documents how to switch storage modes in both directions and that each clone installs its own hooks. Signed-off-by: Matías Insaurralde --- app/cli/cmd/trace_init.go | 16 ++++++++++++++++ app/cli/cmd/trace_init_local_test.go | 10 ++++++++++ app/cli/internal/trace/README.md | 9 +++++++++ 3 files changed, 35 insertions(+) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index eae02fa1c..b1069c69f 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -265,6 +265,14 @@ with --local; --require-trace can.`, organization := cmp.Or(cfg.organization, viper.GetString(confOptions.organization.viperKey)) writeTraceInitSummary(os.Stdout, organization, cfg.project, cfg.workflow, providerNames(installed)) + + // A control-plane init that cleared a previous LOCAL policy changed + // how every push behaves, so say it rather than leave it to a debug + // log the user never sees. + if existingMode == config.TraceStorageLocal { + writeTraceStorageSwitchedNote(os.Stdout, config.ChainloopYMLName(repoRoot)) + } + writeTraceNextSteps(os.Stdout, repoRoot, workDir, installed) return nil @@ -364,6 +372,14 @@ func writeTraceInitLocalSummary(w io.Writer, repoRoot string, harnesses []string fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) } +// writeTraceStorageSwitchedNote tells the user that this control-plane init +// cleared a previous local-mode policy. The switch changes how every push +// behaves, so it is stated plainly here rather than left to a debug log, and the +// user is reminded to commit it like any other repository policy change. +func writeTraceStorageSwitchedNote(w io.Writer, ymlName string) { + fmt.Fprintf(w, "\nThis repository was in local mode. Trace now attests sessions to the control\nplane. Commit %s so every clone switches too.\n", ymlName) +} + // 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 diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go index 067ae5d70..06df0c809 100644 --- a/app/cli/cmd/trace_init_local_test.go +++ b/app/cli/cmd/trace_init_local_test.go @@ -145,6 +145,16 @@ func TestTraceInitInvalidStorage(t *testing.T) { }) } +func TestWriteTraceStorageSwitchedNote(t *testing.T) { + out := &bytes.Buffer{} + writeTraceStorageSwitchedNote(out, ".chainloop.yml") + + got := out.String() + assert.Contains(t, got, "was in local mode") + assert.Contains(t, got, "control") + assert.Contains(t, got, ".chainloop.yml") +} + 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 diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index ca2d3d05c..79d5090aa 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -102,6 +102,15 @@ How the sessions are linked to the commits and attested. | 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 | +### Switching storage modes + +`traceStorage` in `.chainloop.yml` is a committed repository policy, so it is the same for every clone: once a repository is `LOCAL`, every session every user records is stored on disk. To change the mode: + +- **To local:** run `chainloop trace init --local`. It writes `traceStorage: LOCAL` (and `tracePath` with `--trace-path`), installs the hooks, and contacts no control plane. Commit `.chainloop.yml` to share the choice. +- **Back to the control plane:** run `chainloop trace init` (without `--local`). It writes `traceStorage: CONTROL_PLANE`, clearing the local policy, and sets up the workflow as usual. The command tells you it switched. Commit `.chainloop.yml`. + +The git hooks live under `.git` and are not cloned, so each teammate runs `chainloop trace init` (or `chainloop trace init --local`) once after cloning to install their own. + ## How each agent is hooked in | Agent | Hook installation | Hooks | From db6b41939b3db183661b5ec42b637aad3e1be952 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 12:44:34 +0200 Subject: [PATCH 08/19] feat(trace): online and offline trace modes that can run together Replace the single traceStorage value and the --local flag with a traceModes list in .chainloop.yml and the --online and --offline flags of trace init. A repository can attest to the control plane, store sessions on disk, or do both. With both modes, the pre-push hook stores the offline copy first, then attests. A failed mode keeps the sessions for the next push. - trace init with no mode flag keeps the modes in .chainloop.yml. - --trace-path no longer turns on the offline mode. - An online init that fails on authentication, or cannot reach the control plane, suggests "chainloop trace init --offline". - The session-start banner names the active trace modes. - A clone of an offline repository needs no trace init: the first traced session installs the git hooks, as in the online mode. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/root.go | 6 +- app/cli/cmd/telemetry_report.go | 4 +- app/cli/cmd/trace_hook.go | 32 +- app/cli/cmd/trace_init.go | 383 ++++++++++-------- app/cli/cmd/trace_init_local_test.go | 236 ----------- app/cli/cmd/trace_init_modes_test.go | 290 +++++++++++++ app/cli/cmd/trace_init_test.go | 23 +- app/cli/cmd/trace_run.go | 35 +- app/cli/documentation/cli-reference.md | 37 +- app/cli/internal/repositoryconfig/config.go | 13 +- app/cli/internal/trace/README.md | 23 +- app/cli/internal/trace/config/config.go | 105 +++-- app/cli/internal/trace/config/modes_test.go | 165 ++++++++ app/cli/internal/trace/config/storage_test.go | 158 -------- app/cli/internal/trace/state/state.go | 6 +- app/cli/internal/trace/state/state_test.go | 4 +- app/cli/pkg/action/trace_agent_hook.go | 131 +++--- app/cli/pkg/action/trace_agent_hook_test.go | 6 +- app/cli/pkg/action/trace_banner_test.go | 12 +- app/cli/pkg/action/trace_hook_handler.go | 198 ++++++--- app/cli/pkg/action/trace_local_store.go | 71 +--- app/cli/pkg/action/trace_local_store_test.go | 161 ++++++-- app/cli/pkg/action/trace_run.go | 106 +++-- 23 files changed, 1253 insertions(+), 952 deletions(-) delete mode 100644 app/cli/cmd/trace_init_local_test.go create mode 100644 app/cli/cmd/trace_init_modes_test.go create mode 100644 app/cli/internal/trace/config/modes_test.go delete mode 100644 app/cli/internal/trace/config/storage_test.go diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 4e2b18ea1..7629fcf9a 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -64,7 +64,7 @@ const ( skipActionOptsInit = "skipActionOptsInit" // 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, + // `trace run` is skipActionOptsInit so an offline-only run opens no connection, // but a control-plane run of it must still be reported as it was before. reportTelemetryAnyway = "reportTelemetryAnyway" appName = "chainloop" @@ -158,7 +158,7 @@ func handleOrganizationExecutionError(executed *cobra.Command, err error) error // 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 +// instead. A trace command in the offline mode alone 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() { @@ -278,7 +278,7 @@ Command reference: ` + cliReferenceURL, // 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. + // branch, so an offline-only repository makes no control-plane call at all. if cmd.Annotations[skipActionOptsInit] == trueString { return nil } diff --git a/app/cli/cmd/telemetry_report.go b/app/cli/cmd/telemetry_report.go index 9b8d52cd9..9f205c4a9 100644 --- a/app/cli/cmd/telemetry_report.go +++ b/app/cli/cmd/telemetry_report.go @@ -121,8 +121,8 @@ func telemetryIdentity() *token.ParsedToken { // Beyond that, commands annotated skipActionOptsInit are the local-only ones, such as the // 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. +// skipActionOptsInit so an offline-only run opens no connection, yet an online run of +// it must still be reported as it was before the offline mode existed. func shouldReportCommand(executed *cobra.Command) bool { if executed == nil || isTelemetryCommand(executed) || isCompletionCommand(executed) { return false diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go index f4974cad7..dd2664444 100644 --- a/app/cli/cmd/trace_hook.go +++ b/app/cli/cmd/trace_hook.go @@ -123,9 +123,9 @@ func newTraceHookGitPrePushCmd() *cobra.Command { // action.PrePushFailure in Execute. // // skipActionOptsInit keeps root from building the control-plane - // connection (and probing the current org) up front: a LOCAL-mode + // connection (and probing the current org) up front: an offline-only // repository must reach no control plane, so the connection is built - // lazily below, only when the storage mode is CONTROL_PLANE. + // lazily below, only when the online mode is enabled. Annotations: map[string]string{ prePushHookAnnotation: trueString, skipActionOptsInit: trueString, @@ -135,25 +135,27 @@ func newTraceHookGitPrePushCmd() *cobra.Command { // The hook runs at the repository root, so "." resolves the nearest // .chainloop.yml the same way the push identity does. - mode, err := config.LoadTraceStorageFromYML(".") + modes, err := config.LoadTraceModesFromYML(".") if err != nil { return err } - if mode == config.TraceStorageLocal { - return action.HandlePrePushHookLocal(cmd.Context(), logger) + // Online: build ActionOpts lazily, since this command is + // skipActionOptsInit and root did not. The offline mode alone + // reaches no control plane. + if modes.Online { + if err := ensureActionOpts(cmd); err != nil { + return err + } } - // 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, - Mode: aicodingsession.ModeCoding, + return action.HandlePrePushHook(cmd.Context(), logger, action.RunTraceStoreOpts{ + Modes: modes, + Push: action.RunTracePushOpts{ + ActionOpts: ActionOpts, + CLIVersion: Version, + Mode: aicodingsession.ModeCoding, + }, }) }, } diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index b1069c69f..155b0ec93 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -35,6 +35,8 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "github.com/spf13/cobra" "github.com/spf13/viper" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // newTraceInitCmd creates the trace init subcommand. @@ -42,7 +44,8 @@ func newTraceInitCmd() *cobra.Command { var ( project string contract string - localFlag bool + onlineFlag bool + offlineFlag bool tracePathFlag string claudeFlag bool cursorFlag bool @@ -53,8 +56,8 @@ func newTraceInitCmd() *cobra.Command { 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). + // ensureActionOpts below; the offline mode alone 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 @@ -80,32 +83,49 @@ 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.`, +The trace modes select where a session's evidence goes. --online (the default) +attests it to the Chainloop control plane. --offline stores it on disk, under +.git/chainloop-trace/local-sessions or the --trace-path directory, and needs no +login and no control plane. Pass both to do both. With no mode flag, init keeps +the modes in .chainloop.yml, or uses --online on a first run. The modes are +saved to .chainloop.yml as traceModes, so every clone records the same way. + +With --offline alone, init installs the same hooks but creates no workflow, +needs no login, and reaches no control plane. --contract, --workflow, --org and +--project are control-plane settings and need --online; --require-trace does +not. + +Run init once per repository. In a clone of an initialized repository, the +first traced session installs the git hooks.`, 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 + // An explicit mode flag overrides the modes in .chainloop.yml, also + // an unreadable value. With no mode flag, an unreadable value is an + // error before anything is installed: every pre-push would fail on + // it, so init must not report success over it. + existingModes, existingErr := config.LoadTraceModesFromYML(repoRoot) + modes, saveModes, err := resolveTraceModes(traceModeFlags{ + online: onlineFlag, + onlineSet: cmd.Flags().Changed("online"), + offline: offlineFlag, + offlineSet: cmd.Flags().Changed("offline"), + }, existingModes, existingErr) + if err != nil { + return err } - // 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 { + // The offline mode alone 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 !modes.Online { 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) + return fmt.Errorf("--%s cannot be used without --online: the offline mode contacts no control plane and creates no workflow", name) } } } @@ -114,38 +134,24 @@ with --local; --require-trace can.`, if err != nil { return err } - cfg.local = localFlag + cfg.modes = modes + cfg.saveModes = saveModes 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) + cfg.saveTracePath = tracePathFlag != "" - // 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. + // The online mode fills in the identity and opens an executor, + // building the connection lazily (the command is + // skipActionOptsInit). The offline mode alone resolves no identity + // and opens no executor. var executor *action.AttestationExecutor - if !localFlag { + if modes.Online { if err := ensureActionOpts(cmd); err != nil { - return err + return withOfflineHint(err) } executor, err = resolveTraceIdentity(cmd.Context(), cfg, repoRoot) if err != nil { - return stopIfAborted(err) + return withOfflineHint(stopIfAborted(err)) } defer func() { _ = executor.Close() }() } @@ -165,10 +171,10 @@ with --local; --require-trace can.`, return fmt.Errorf("no trace providers selected") } - // Local mode has no control plane to create the workflow in. - if !localFlag { + // The offline mode has no control plane to create the workflow in. + if modes.Online { if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil { - return err + return withOfflineHint(err) } } @@ -232,6 +238,10 @@ with --local; --require-trace can.`, // local, but a failure part-way through it would leave a repository // half set up: harness hooks calling a configuration that is not // there, or a configuration with nothing recording into it. + // Read before writeTraceInitState marks the repository initialized: + // a change of modes is news only to a repository that was set up. + wasInitialized := state.NewGitStore(gitDir).IsTraceInitialized() + if err := writeTraceInitState(cfg, repoRoot, gitDir); err != nil { for _, snapshot := range before { if rerr := snapshot.restore(); rerr != nil { @@ -252,28 +262,29 @@ with --local; --require-trace can.`, // 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 + summary := traceInitSummary{modes: modes, harnesses: providerNames(installed)} + if modes.Online { + // 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. + summary.organization = cmp.Or(cfg.organization, viper.GetString(confOptions.organization.viperKey)) + summary.project = cfg.project + summary.workflow = cfg.workflow + } + if modes.Offline { + summary.sessionsPath = traceSessionsPathForDisplay(repoRoot, gitDir, workDir) } - // 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)) - - writeTraceInitSummary(os.Stdout, organization, cfg.project, cfg.workflow, providerNames(installed)) + writeTraceInitSummary(os.Stdout, summary) - // A control-plane init that cleared a previous LOCAL policy changed + // A change of modes in a repository that was already set up changes // how every push behaves, so say it rather than leave it to a debug // log the user never sees. - if existingMode == config.TraceStorageLocal { - writeTraceStorageSwitchedNote(os.Stdout, config.ChainloopYMLName(repoRoot)) + if saveModes && existingErr == nil && (wasInitialized || existingModes != (config.TraceModes{Online: true})) { + writeTraceModesChangedNote(os.Stdout, existingModes, modes, config.ChainloopYMLName(repoRoot)) } - writeTraceNextSteps(os.Stdout, repoRoot, workDir, installed) + writeTraceNextSteps(os.Stdout, modes, repoRoot, workDir, installed) return nil }, @@ -281,8 +292,9 @@ with --local; --require-trace can.`, 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().BoolVar(&onlineFlag, "online", false, "attest sessions to the Chainloop control plane (the default when no mode flag is set and .chainloop.yml has no traceModes)") + cmd.Flags().BoolVar(&offlineFlag, "offline", false, "store sessions on disk, with no control plane, login or workflow; combine with --online to do both") + cmd.Flags().StringVar(&tracePathFlag, "trace-path", "", "where the offline mode stores sessions (default: .git/chainloop-trace/local-sessions)") 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 a required trace is missing") cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)") @@ -296,35 +308,82 @@ with --local; --require-trace can.`, // done with it, which is more than belongs in a command's own output. const traceDocsURL = "https://docs.chainloop.dev/ai-sessions" +// traceInitSummary is what init reports it set up. The online fields are empty +// for the offline mode alone, and sessionsPath is empty without the offline +// mode. +type traceInitSummary struct { + modes config.TraceModes + organization, project, workflow string + sessionsPath string + harnesses []string +} + // writeTraceInitSummary reports what the repository was set up with. It goes to // stdout rather than through the logger because it is the command's result, // not a note about something that happened on the way there. -func writeTraceInitSummary(w io.Writer, organization, project, workflow string, harnesses []string) { +func writeTraceInitSummary(w io.Writer, s traceInitSummary) { fmt.Fprint(w, "\nCongratulations, your repository is initialized\n\n") - // Empty only when the CLI has no organization configured either, which - // leaves nothing truthful to name. - if organization != "" { - fmt.Fprintf(w, " organization %s\n", organization) + if s.modes.Online { + fmt.Fprintf(w, " modes %s\n", s.modes) + + // Empty only when the CLI has no organization configured either, + // which leaves nothing truthful to name. + if s.organization != "" { + fmt.Fprintf(w, " organization %s\n", s.organization) + } + + fmt.Fprintf(w, " project %s\n", s.project) + fmt.Fprintf(w, " workflow %s\n", s.workflow) + } else { + fmt.Fprintf(w, " modes %s (no control plane)\n", s.modes) + } + + if s.modes.Offline { + fmt.Fprintf(w, " sessions stored on disk under %s on push\n", s.sessionsPath) + } + + fmt.Fprintf(w, " harnesses %s\n", strings.Join(s.harnesses, ", ")) + + // The offline mode alone leaves the control plane out. Say how to bring + // it in, so the choice does not look final. + if !s.modes.Online { + fmt.Fprint(w, "\nTo also send sessions to a Chainloop control plane later, run:\n chainloop trace init --online --offline\n") + } +} + +// writeTraceModesChangedNote tells the user that this init changed the trace +// modes of a repository that was already set up. The change affects how every +// push behaves, so it is stated plainly here rather than left to a debug log, +// and the user is reminded to commit it like any other repository policy +// change. +func writeTraceModesChangedNote(w io.Writer, from, to config.TraceModes, ymlName string) { + fmt.Fprintf(w, "\nTrace modes changed from %s to %s. Commit %s so every clone\nrecords the same way.\n", from, to, ymlName) +} + +// traceSessionsPathForDisplay names where the offline mode stores sessions, +// relative to where the user ran init when that is shorter to read. +func traceSessionsPathForDisplay(repoRoot, gitDir, workDir string) string { + path := action.ResolveTracePath(repoRoot, state.NewGitStore(gitDir), config.LoadTracePathFromYML(repoRoot)) + if rel, err := filepath.Rel(workDir, path); err == nil { + return rel } - fmt.Fprintf(w, " project %s\n", project) - fmt.Fprintf(w, " workflow %s\n", workflow) - fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) + return path } // writeTraceNextSteps says what is left for the user to do. Committing comes // first: what init wrote into the repository is shared, and until it is // committed this setup exists in one working copy only. // -// It stops short of promising that a teammate who pulls is set up: the git -// hooks live in .git/hooks, which git does not carry between clones, so they -// still have to run init themselves. What they gain is having nothing to answer -// when they do. +// With the online mode it stops short of promising that a teammate who pulls +// is set up: a teammate needs their own login. The offline mode needs nothing +// of the kind, and the first traced session in a clone installs the git hooks, +// so a teammate does not run init at all. // // workDir is where the user ran the command, which is what `git add` resolves // its arguments against; it is not always the repository root. -func writeTraceNextSteps(w io.Writer, repoRoot, workDir string, installed []trace.Provider) { +func writeTraceNextSteps(w io.Writer, modes config.TraceModes, repoRoot, workDir string, installed []trace.Provider) { files := make([]string, 0, len(installed)+1) add := func(absolute string) { // An absolute path stages just as well, so it is the fallback for the @@ -343,82 +402,35 @@ func writeTraceNextSteps(w io.Writer, repoRoot, workDir string, installed []trac add(p.SettingsFile(repoRoot)) } - fmt.Fprintf(w, ` -What's next - - 1. Commit these files. Teammates then only need to run chainloop trace init: - git add %s - 2. Start %s and write some code - 3. Commit and push as usual. The AI coding sessions behind those commits are - recorded and stored automatically - -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") + commit := "Commit these files. Teammates then only need to run chainloop trace init:" + if !modes.Online { + commit = "Commit these files so every clone records the same way:" } - fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) -} - -// writeTraceStorageSwitchedNote tells the user that this control-plane init -// cleared a previous local-mode policy. The switch changes how every push -// behaves, so it is stated plainly here rather than left to a debug log, and the -// user is reminded to commit it like any other repository policy change. -func writeTraceStorageSwitchedNote(w io.Writer, ymlName string) { - fmt.Fprintf(w, "\nThis repository was in local mode. Trace now attests sessions to the control\nplane. Commit %s so every clone switches too.\n", ymlName) -} - -// 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)) + var push string + switch { + case modes.Online && modes.Offline: + push = `The AI coding sessions behind those commits are + recorded in Chainloop and written to disk automatically; the push tells + you where` + case modes.Offline: + push = `The session evidence is written to disk, with no + control plane; the push tells you where` + default: + push = `The AI coding sessions behind those commits are + recorded and stored automatically` } fmt.Fprintf(w, ` What's next - 1. Commit these files so the local-mode policy is shared: + 1. %s 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 + 3. Commit and push as usual. %s Learn more: %s -`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL) +`, commit, strings.Join(files, " "), joinWithOr(providerNames(installed)), push, traceDocsURL) } // providerNames names the harnesses, in the order they were installed. @@ -607,37 +619,70 @@ 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 + // modes are the trace modes of the repository after this run, written + // when saveModes is set. tracePath is the optional override for where the + // offline mode stores sessions. + modes config.TraceModes 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 + saveModes, 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 +// traceModeFlags are the --online and --offline flags, and whether the user +// passed each one. +type traceModeFlags struct { + online, onlineSet bool + offline, offlineSet bool +} + +// resolveTraceModes decides the trace modes init sets up and whether it writes +// them to .chainloop.yml. A mode flag sets the modes outright: the passed flags +// are the modes. With no mode flag, init keeps the existing modes, which are +// online only when .chainloop.yml has none. The modes are written only when +// they change, so a fresh online repository keeps its config minimal. +// existingErr is the error from reading the existing modes: a mode flag +// overrides the unreadable value, and with no mode flag it is returned. +func resolveTraceModes(flags traceModeFlags, existing config.TraceModes, existingErr error) (config.TraceModes, bool, error) { + if !flags.onlineSet && !flags.offlineSet { + if existingErr != nil { + return config.TraceModes{}, false, fmt.Errorf("existing .chainloop.yml: %w", existingErr) + } + + return existing, false, nil + } + + modes := config.TraceModes{Online: flags.online, Offline: flags.offline} + if !modes.Online && !modes.Offline { + return config.TraceModes{}, false, errors.New("set at least one trace mode: --online, --offline, or both") + } + + return modes, existingErr != nil || modes != existing, nil +} + +// offlineHint is added to an online init error when the control plane cannot +// be used, because the offline mode needs no control plane. +const offlineHint = `To trace without a Chainloop control plane, run "chainloop trace init --offline"` + +// withOfflineHint adds offlineHint to an error that tells the control plane is +// not available: the credentials are missing or rejected, or the control plane +// cannot be reached. Any other error is returned unchanged. +func withOfflineHint(err error) error { + if err == nil { + return nil } + + if msg, ok := action.AuthErrorMessage(err); ok { + return fmt.Errorf("%s\n%s", msg, offlineHint) + } + + if st, ok := status.FromError(err); ok && st.Code() == codes.Unavailable { + return fmt.Errorf("%w\n%s", err, offlineHint) + } + + return err } // resolveTraceInitConfig reads the trace settings from the flags, falling back @@ -695,13 +740,13 @@ 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 { + // Trace modes first: the offline mode is what makes the push hook store + // on disk, so it must land even if nothing else is written. + if c.saveModes { + if err := config.SaveTraceModesToYML(repoRoot, c.modes); err != nil { return err } - logger.Debug().Str("traceStorage", c.traceStorage).Msg("traceStorage saved to .chainloop.yml") + logger.Debug().Str("traceModes", c.modes.String()).Msg("traceModes saved to .chainloop.yml") } if c.saveTracePath { diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go deleted file mode 100644 index 06df0c809..000000000 --- a/app/cli/cmd/trace_init_local_test.go +++ /dev/null @@ -1,236 +0,0 @@ -// -// 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 TestWriteTraceStorageSwitchedNote(t *testing.T) { - out := &bytes.Buffer{} - writeTraceStorageSwitchedNote(out, ".chainloop.yml") - - got := out.String() - assert.Contains(t, got, "was in local mode") - assert.Contains(t, got, "control") - assert.Contains(t, got, ".chainloop.yml") -} - -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_init_modes_test.go b/app/cli/cmd/trace_init_modes_test.go new file mode 100644 index 000000000..d39fe6883 --- /dev/null +++ b/app/cli/cmd/trace_init_modes_test.go @@ -0,0 +1,290 @@ +// +// 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" + "errors" + "fmt" + "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" + jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +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() +} + +// TestTraceInitOffline covers R-008/R-011: `trace init --offline` sets the +// repository up for the offline mode with no control plane, writing +// traceModes: [offline] and no control-plane identity. +func TestTraceInitOffline(t *testing.T) { + t.Run("writes the offline mode and no identity, installs hooks", func(t *testing.T) { + repoDir := t.TempDir() + gitInit(t, repoDir) + + require.NoError(t, runTraceInit(t, repoDir, "--offline", "--claude")) + + modes, err := config.LoadTraceModesFromYML(repoDir) + require.NoError(t, err) + assert.Equal(t, config.TraceModes{Offline: true}, modes) + + data, err := os.ReadFile(filepath.Join(repoDir, chainloopYMLName)) + require.NoError(t, err) + got := string(data) + 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 is written with --offline", func(t *testing.T) { + repoDir := t.TempDir() + gitInit(t, repoDir) + + require.NoError(t, runTraceInit(t, repoDir, "--offline", "--trace-path", "evidence/sessions", "--claude")) + + assert.Equal(t, "evidence/sessions", config.LoadTracePathFromYML(repoDir)) + }) + + 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, "--offline", "--require-trace", "--claude")) + + assert.True(t, config.LoadTraceRequiredFromYML(repoDir)) + }) + + t.Run("control-plane flags are rejected without --online", 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{"--offline", "--claude"}, flag...) + err := runTraceInit(t, repoDir, args...) + assert.ErrorContains(t, err, "cannot be used without --online", "flag %v", flag) + } + }) + + t.Run("a second run with no mode flag keeps the offline mode", func(t *testing.T) { + repoDir := t.TempDir() + gitInit(t, repoDir) + + require.NoError(t, runTraceInit(t, repoDir, "--offline", "--claude")) + // No control plane is reachable here, so a run that fell back to the + // online default would fail. + require.NoError(t, runTraceInit(t, repoDir, "--claude")) + + modes, err := config.LoadTraceModesFromYML(repoDir) + require.NoError(t, err) + assert.Equal(t, config.TraceModes{Offline: true}, modes) + }) +} + +// TestTraceInitInvalidModes covers the guard that an init over an invalid +// traceModes fails before installing hooks, so init never reports success for +// a repository whose every pre-push would then error, while an explicit mode +// flag overrides the bad value. +func TestTraceInitInvalidModes(t *testing.T) { + t.Run("init with no mode flag over an invalid traceModes errors before install", func(t *testing.T) { + repoDir := t.TempDir() + gitInit(t, repoDir) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, chainloopYMLName), + []byte("traceModes: [bogus]\n"), 0600)) + + err := runTraceInit(t, repoDir, "--claude") + require.Error(t, err) + assert.Contains(t, err.Error(), "traceModes") + + // 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("--offline overrides an invalid traceModes", func(t *testing.T) { + repoDir := t.TempDir() + gitInit(t, repoDir) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, chainloopYMLName), + []byte("traceModes: [bogus]\n"), 0600)) + + require.NoError(t, runTraceInit(t, repoDir, "--offline", "--claude")) + + modes, err := config.LoadTraceModesFromYML(repoDir) + require.NoError(t, err) + assert.Equal(t, config.TraceModes{Offline: true}, modes) + }) +} + +func TestResolveTraceModes(t *testing.T) { + online := config.TraceModes{Online: true} + offline := config.TraceModes{Offline: true} + both := config.TraceModes{Online: true, Offline: true} + errBroken := errors.New("broken traceModes") + + testCases := []struct { + name string + flags traceModeFlags + existing config.TraceModes + existingErr error + want config.TraceModes + wantSave bool + wantErr bool + }{ + {name: "no flag on a fresh repository is online and writes nothing", existing: online, want: online}, + {name: "no flag keeps an existing offline mode", existing: offline, want: offline}, + {name: "no flag keeps existing both modes", existing: both, want: both}, + {name: "no flag over a broken value is an error", existingErr: errBroken, wantErr: true}, + {name: "--offline alone is offline only", flags: traceModeFlags{offline: true, offlineSet: true}, existing: online, want: offline, wantSave: true}, + {name: "--offline again writes nothing", flags: traceModeFlags{offline: true, offlineSet: true}, existing: offline, want: offline}, + {name: "--online --offline is both", flags: traceModeFlags{online: true, onlineSet: true, offline: true, offlineSet: true}, existing: online, want: both, wantSave: true}, + {name: "--online over offline switches to online", flags: traceModeFlags{online: true, onlineSet: true}, existing: offline, want: online, wantSave: true}, + {name: "--online on a fresh repository writes nothing", flags: traceModeFlags{online: true, onlineSet: true}, existing: online, want: online}, + {name: "a flag overrides a broken value", flags: traceModeFlags{offline: true, offlineSet: true}, existingErr: errBroken, want: offline, wantSave: true}, + {name: "every flag false is an error", flags: traceModeFlags{onlineSet: true}, existing: online, wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, save, err := resolveTraceModes(tc.flags, tc.existing, tc.existingErr) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.wantSave, save) + }) + } +} + +func TestWriteTraceInitSummaryModes(t *testing.T) { + t.Run("offline only names no control plane and how to connect one later", func(t *testing.T) { + out := &bytes.Buffer{} + writeTraceInitSummary(out, traceInitSummary{ + modes: config.TraceModes{Offline: true}, + sessionsPath: ".git/chainloop-trace/local-sessions", + harnesses: []string{providerClaudeCode, "cursor"}, //nolint:goconst // provider wire name, kept literal like the other provider tests + }) + + got := out.String() + assert.Contains(t, got, "offline (no control plane)") + assert.Contains(t, got, ".git/chainloop-trace/local-sessions") + assert.Contains(t, got, "claude-code, cursor") + assert.NotContains(t, got, "organization") + assert.Contains(t, got, "chainloop trace init --online --offline") + }) + + t.Run("both modes name the control plane and the disk", func(t *testing.T) { + out := &bytes.Buffer{} + writeTraceInitSummary(out, traceInitSummary{ + modes: config.TraceModes{Online: true, Offline: true}, + organization: "acme", + project: "backend-api", + workflow: defaultTraceWorkflow, + sessionsPath: ".git/chainloop-trace/local-sessions", + harnesses: []string{providerClaudeCode}, + }) + + got := out.String() + assert.Contains(t, got, "online, offline") + assert.Contains(t, got, "acme") + assert.Contains(t, got, "backend-api") + assert.Contains(t, got, ".git/chainloop-trace/local-sessions") + assert.NotContains(t, got, "--online --offline", "a repository with the online mode needs no hint to connect") + }) +} + +func TestWriteTraceNextStepsOffline(t *testing.T) { + repoRoot := t.TempDir() + out := &bytes.Buffer{} + writeTraceNextSteps(out, config.TraceModes{Offline: true}, 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 first session in a clone installs the git hooks, so a teammate does + // not run init again. + assert.NotContains(t, got, "chainloop trace init") +} + +func TestWriteTraceModesChangedNote(t *testing.T) { + out := &bytes.Buffer{} + writeTraceModesChangedNote(out, config.TraceModes{Offline: true}, config.TraceModes{Online: true, Offline: true}, ".chainloop.yml") + + got := out.String() + assert.Contains(t, got, "from offline to online, offline") + assert.Contains(t, got, ".chainloop.yml") +} + +// TestWithOfflineHint covers the hint an online init adds when the control +// plane rejects the credentials: the offline mode needs none. +func TestWithOfflineHint(t *testing.T) { + t.Run("an authentication error suggests the offline mode", func(t *testing.T) { + authErr := fmt.Errorf("resolve organization: %w", + status.Error(codes.Unauthenticated, jwtMiddleware.ErrMissingJwtToken.Message)) + + got := withOfflineHint(authErr) + require.Error(t, got) + assert.Contains(t, got.Error(), `please run "chainloop auth login"`) + assert.Contains(t, got.Error(), "chainloop trace init --offline") + }) + + t.Run("an unreachable control plane suggests the offline mode", func(t *testing.T) { + got := withOfflineHint(status.Error(codes.Unavailable, "connection refused")) + require.Error(t, got) + assert.Contains(t, got.Error(), "connection refused") + assert.Contains(t, got.Error(), "chainloop trace init --offline") + }) + + t.Run("any other error is unchanged", func(t *testing.T) { + other := errors.New("boom") + assert.Equal(t, other, withOfflineHint(other)) + assert.NoError(t, withOfflineHint(nil)) + }) +} diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index 2b16b373f..d336159a0 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -22,6 +22,7 @@ import ( "slices" "testing" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -341,7 +342,7 @@ func TestWriteTraceNextSteps(t *testing.T) { t.Run(tc.name, func(t *testing.T) { out := &bytes.Buffer{} repoRoot := t.TempDir() - writeTraceNextSteps(out, repoRoot, repoRoot, providers.ByNames(tc.providers)) + writeTraceNextSteps(out, config.TraceModes{Online: true}, repoRoot, repoRoot, providers.ByNames(tc.providers)) got := out.String() for _, f := range tc.wantFiles { @@ -363,7 +364,7 @@ func TestWriteTraceNextStepsFromSubdirectory(t *testing.T) { require.NoError(t, os.MkdirAll(workDir, 0750)) out := &bytes.Buffer{} - writeTraceNextSteps(out, repoRoot, workDir, providers.ByNames([]string{providerClaudeCode})) + writeTraceNextSteps(out, config.TraceModes{Online: true}, repoRoot, workDir, providers.ByNames([]string{providerClaudeCode})) got := out.String() assert.Contains(t, got, filepath.Join("..", "..", chainloopYMLName)) @@ -373,8 +374,13 @@ func TestWriteTraceNextStepsFromSubdirectory(t *testing.T) { func TestWriteTraceInitSummary(t *testing.T) { t.Run("every value is reported", func(t *testing.T) { out := &bytes.Buffer{} - writeTraceInitSummary(out, "acme", "backend-api", defaultTraceWorkflow, - []string{providerClaudeCode, "cursor"}) + writeTraceInitSummary(out, traceInitSummary{ + modes: config.TraceModes{Online: true}, + organization: "acme", + project: "backend-api", + workflow: defaultTraceWorkflow, + harnesses: []string{providerClaudeCode, "cursor"}, + }) got := out.String() assert.Contains(t, got, "your repository is initialized") @@ -382,11 +388,18 @@ func TestWriteTraceInitSummary(t *testing.T) { assert.Contains(t, got, "backend-api") assert.Contains(t, got, defaultTraceWorkflow) assert.Contains(t, got, "claude-code, cursor") + assert.Contains(t, got, "online") + assert.NotContains(t, got, "offline") }) t.Run("no organization anywhere leaves the line out rather than guessing", func(t *testing.T) { out := &bytes.Buffer{} - writeTraceInitSummary(out, "", "backend-api", defaultTraceWorkflow, []string{providerClaudeCode}) + writeTraceInitSummary(out, traceInitSummary{ + modes: config.TraceModes{Online: true}, + project: "backend-api", + workflow: defaultTraceWorkflow, + harnesses: []string{providerClaudeCode}, + }) assert.NotContains(t, out.String(), "organization") }) diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index ef349ecfb..0523ead5c 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -34,11 +34,12 @@ Sessions without git commits are still attested. 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 +traceModes, though: in an offline repository (traceModes: [offline]) 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 +the --org, --project and --workflow flags are not required. With both modes, +the session is written to disk and attested. 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 +preserving stored offline 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 @@ -64,7 +65,7 @@ func newTraceRunCmd() *cobra.Command { 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). + // ensureActionOpts, and an offline-only 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 @@ -96,14 +97,13 @@ func newTraceRunCmd() *cobra.Command { store = state.NewGitStore(gitDir) } - // The storage mode is a repository policy, read even though trace - // run keeps its identity flag-only: a LOCAL repository stores to + // The trace modes are a repository policy, read even though trace + // run keeps its identity flag-only: an offline-only repository stores to // disk and needs no identity and no control plane. - storageMode, err := config.LoadTraceStorageFromYML(repoRoot) + modes, err := config.LoadTraceModesFromYML(repoRoot) if err != nil { return err } - local := storageMode == config.TraceStorageLocal var ( organization string @@ -111,10 +111,11 @@ func newTraceRunCmd() *cobra.Command { 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. + if modes.Online { + // An online run is reported in telemetry as it was before the + // offline mode made this command skipActionOptsInit. An + // offline-only 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, @@ -141,7 +142,7 @@ func newTraceRunCmd() *cobra.Command { contractName, contractRequired = config.ResolveContract(contractFlag) // Build ActionOpts lazily (this command is skipActionOptsInit), - // so a LOCAL run never opens a control-plane connection. + // so an offline-only run never opens a control-plane connection. if err := ensureActionOpts(cmd); err != nil { return err } @@ -160,20 +161,20 @@ func newTraceRunCmd() *cobra.Command { ContractRequired: contractRequired, ActionOpts: ActionOpts, CLIVersion: Version, - Local: local, + Modes: modes, }) }, } - 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(&projectFlag, "project", "", "chainloop project name (required unless the repository is offline only; .chainloop.yml identity is ignored)") + cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name used for trace attestations (required unless the repository is offline only; .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") - // project and workflow are not required in local mode, so the check is done + // project and workflow are not required offline only, 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 7f31d908c..d37c64807 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -3322,13 +3322,20 @@ 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. +The trace modes select where a session's evidence goes. --online (the default) +attests it to the Chainloop control plane. --offline stores it on disk, under +.git/chainloop-trace/local-sessions or the --trace-path directory, and needs no +login and no control plane. Pass both to do both. With no mode flag, init keeps +the modes in .chainloop.yml, or uses --online on a first run. The modes are +saved to .chainloop.yml as traceModes, so every clone records the same way. + +With --offline alone, init installs the same hooks but creates no workflow, +needs no login, and reaches no control plane. --contract, --workflow, --org and +--project are control-plane settings and need --online; --require-trace does +not. + +Run init once per repository. In a clone of an initialized repository, the +first traced session installs the git hooks. ``` chainloop trace init [flags] @@ -3341,11 +3348,12 @@ Options --contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for init ---local set up local mode: no control plane, no login, no workflow; the pre-push hook stores session evidence on disk +--offline store sessions on disk, with no control plane, login or workflow; combine with --online to do both +--online attest sessions to the Chainloop control plane (the default when no mode flag is set and .chainloop.yml has no traceModes) --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 +--trace-path string where the offline mode stores sessions (default: .git/chainloop-trace/local-sessions) --workflow string chainloop workflow name used for trace attestations (defaults to "ai-coding-session") ``` @@ -3379,11 +3387,12 @@ Sessions without git commits are still attested. 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 +traceModes, though: in an offline repository (traceModes: [offline]) 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 +the --org, --project and --workflow flags are not required. With both modes, +the session is written to disk and attested. 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 +preserving stored offline 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 @@ -3402,9 +3411,9 @@ Options --cursor install Cursor hooks -h, --help help for run --opencode install opencode hooks ---project string chainloop project name (required unless the repository is in local mode; .chainloop.yml identity is ignored) +--project string chainloop project name (required unless the repository is offline only; .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 unless the repository is in local mode; .chainloop.yml identity is ignored) +--workflow string chainloop workflow name used for trace attestations (required unless the repository is offline only; .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 9382d7061..0cfac4900 100644 --- a/app/cli/internal/repositoryconfig/config.go +++ b/app/cli/internal/repositoryconfig/config.go @@ -41,12 +41,13 @@ type ChainloopYML struct { Organization string `yaml:"organization,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 + // TraceModes selects where Chainloop Trace sends a session's evidence: + // "online" attests it to the control plane, "offline" writes the evidence + // set to disk with no control plane. Both can be set at once. Absent means + // online only. It is a committed, shared repository policy, so every clone + // of the repository records the same way. + TraceModes []string `yaml:"traceModes,omitempty"` + // TracePath names where sessions are stored in offline 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 diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 79d5090aa..6dfeba722 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 where the evidence goes: the Chainloop project, or the on-disk path in local mode (spec issue-3582). | Yes | No | No | No | +| Welcome message | At session start, the user sees a message that tells that Chainloop records the session, in which trace modes, and where the evidence goes: the Chainloop project in the online mode, and the on-disk path in the offline 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,17 +99,24 @@ 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 | 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 | +| Attestation at push | In the online trace mode (the default), the `pre-push` hook attests the sessions of the pushed commits to the control plane. | Yes | Yes | Yes | Yes | +| Offline storage at push | In the offline trace mode (`traceModes: [offline]` 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`), 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. With `traceModes: [online, offline]` the hook does both: it stores the set on disk first, then attests it. `trace init --offline` 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 | -### Switching storage modes +### Trace modes -`traceStorage` in `.chainloop.yml` is a committed repository policy, so it is the same for every clone: once a repository is `LOCAL`, every session every user records is stored on disk. To change the mode: +`traceModes` in `.chainloop.yml` selects where a session's evidence goes. `online` attests it to the control plane. `offline` stores it on disk. A repository can use one mode or both. With no `traceModes`, the mode is `online`. -- **To local:** run `chainloop trace init --local`. It writes `traceStorage: LOCAL` (and `tracePath` with `--trace-path`), installs the hooks, and contacts no control plane. Commit `.chainloop.yml` to share the choice. -- **Back to the control plane:** run `chainloop trace init` (without `--local`). It writes `traceStorage: CONTROL_PLANE`, clearing the local policy, and sets up the workflow as usual. The command tells you it switched. Commit `.chainloop.yml`. +`traceModes` is a committed repository policy, so it is the same for every clone. To change it, run `chainloop trace init` with the modes you want, then commit `.chainloop.yml`: -The git hooks live under `.git` and are not cloned, so each teammate runs `chainloop trace init` (or `chainloop trace init --local`) once after cloning to install their own. +- **Offline only:** `chainloop trace init --offline`. It installs the hooks and contacts no control plane. +- **Online only:** `chainloop trace init --online`. It sets up the workflow as usual. +- **Both:** `chainloop trace init --online --offline`. + +`chainloop trace init` with no mode flag keeps the modes in `.chainloop.yml`. The command tells you when the modes change. + +Run `chainloop trace init` once per repository. The git hooks live under `.git` and are not cloned, but in a clone the first traced session installs them, so a teammate does not run init again. + +When the online mode fails at push time, for example because the control plane is not available, the offline copy is still stored. The session stays in the trace state, and the next push tries the online mode again. ## 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 05c9bd281..117db90b4 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -86,62 +86,87 @@ func SaveRequireTraceToYML(dir string, val bool) error { } 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 + // TraceModeOnline attests each session to the Chainloop control plane. It + // is the mode when traceModes is unset. + TraceModeOnline = "online" + // TraceModeOffline writes each session's evidence set to disk, with no // control plane. - TraceStorageLocal = "LOCAL" + TraceModeOffline = "offline" ) -// 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) +// TraceModes is where Chainloop Trace sends a session's evidence. The modes are +// independent, so a repository can attest to the control plane and keep a copy +// on disk at the same time. +type TraceModes struct { + Online bool + Offline bool +} + +// Names lists the enabled modes in a fixed order, online first, as they are +// written to .chainloop.yml and shown to the user. +func (m TraceModes) Names() []string { + names := make([]string, 0, 2) + if m.Online { + names = append(names, TraceModeOnline) } + if m.Offline { + names = append(names, TraceModeOffline) + } + + return names } -// LoadTraceStorageFromYML reads and canonicalizes the traceStorage mode from the -// nearest .chainloop.yml. A missing config or field is CONTROL_PLANE, but a +// String renders the enabled modes for the user, for example "online, offline". +func (m TraceModes) String() string { + return strings.Join(m.Names(), ", ") +} + +// ParseTraceModes canonicalizes a traceModes list: each entry is trimmed and +// lower-cased, and a repeated entry counts once. An absent or empty list is +// online only. Any entry other than the two known modes is an error, so a +// committed typo surfaces instead of silently falling back to online (and +// pushing to a backend an offline repository means to avoid). +func ParseTraceModes(values []string) (TraceModes, error) { + if len(values) == 0 { + return TraceModes{Online: true}, nil + } + + var modes TraceModes + for _, v := range values { + switch strings.ToLower(strings.TrimSpace(v)) { + case TraceModeOnline: + modes.Online = true + case TraceModeOffline: + modes.Offline = true + default: + return TraceModes{}, fmt.Errorf("unknown trace mode %q in traceModes of .chainloop.yml: want %s or %s", v, TraceModeOnline, TraceModeOffline) + } + } + + return modes, nil +} + +// LoadTraceModesFromYML reads and canonicalizes the traceModes list from the +// nearest .chainloop.yml. A missing config or field is online only, 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) { +// online. An offline repository whose config broke must not have its pre-push +// hook fall back to the control plane and upload an offline-only session. +func LoadTraceModesFromYML(dir string) (TraceModes, error) { cfg, _, err := repositoryconfig.LoadChainloopYML(dir) if errors.Is(err, repositoryconfig.ErrChainloopYMLNotFound) { - return TraceStorageControlPlane, nil + return TraceModes{Online: true}, nil } if err != nil { - return "", err + return TraceModes{}, 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 + return ParseTraceModes(cfg.TraceModes) } -// SaveTraceStorageToYML writes (or updates) the traceStorage field in +// SaveTraceModesToYML writes (or updates) the traceModes field in // .chainloop.yml at the given directory, preserving any other fields. -func SaveTraceStorageToYML(dir, mode string) error { - return updateChainloopYMLField(dir, "traceStorage", mode) +func SaveTraceModesToYML(dir string, modes TraceModes) error { + return updateChainloopYMLField(dir, "traceModes", modes.Names()) } // SaveTracePathToYML writes (or updates) the tracePath field in .chainloop.yml diff --git a/app/cli/internal/trace/config/modes_test.go b/app/cli/internal/trace/config/modes_test.go new file mode 100644 index 000000000..fe6b959cf --- /dev/null +++ b/app/cli/internal/trace/config/modes_test.go @@ -0,0 +1,165 @@ +// +// 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 TestParseTraceModes(t *testing.T) { + testCases := []struct { + name string + values []string + want TraceModes + wantErr bool + }{ + {name: "absent defaults to online", values: nil, want: TraceModes{Online: true}}, + {name: "empty list defaults to online", values: []string{}, want: TraceModes{Online: true}}, + {name: "online", values: []string{"online"}, want: TraceModes{Online: true}}, + {name: "offline", values: []string{"offline"}, want: TraceModes{Offline: true}}, + {name: "both", values: []string{"online", "offline"}, want: TraceModes{Online: true, Offline: true}}, + {name: "order does not matter", values: []string{"offline", "online"}, want: TraceModes{Online: true, Offline: true}}, + {name: "case and space are canonicalized", values: []string{" OFFLINE ", "Online"}, want: TraceModes{Online: true, Offline: true}}, + {name: "a repeated mode is one mode", values: []string{"offline", "offline"}, want: TraceModes{Offline: true}}, + {name: "an unknown mode is an error", values: []string{"local"}, wantErr: true}, + {name: "an empty entry is an error", values: []string{"online", ""}, wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseTraceModes(tc.values) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestTraceModesNames(t *testing.T) { + assert.Equal(t, []string{"online"}, TraceModes{Online: true}.Names()) + assert.Equal(t, []string{"offline"}, TraceModes{Offline: true}.Names()) + assert.Equal(t, []string{"online", "offline"}, TraceModes{Online: true, Offline: true}.Names()) + assert.Empty(t, TraceModes{}.Names()) +} + +func TestLoadTraceModesFromYML(t *testing.T) { + testCases := []struct { + name string + yml string + want TraceModes + wantErr bool + }{ + {name: "no field is online", yml: "projectName: p\n", want: TraceModes{Online: true}}, + {name: "offline only", yml: "traceModes:\n - offline\n", want: TraceModes{Offline: true}}, + {name: "both in flow style", yml: "traceModes: [online, offline]\n", want: TraceModes{Online: true, Offline: true}}, + {name: "an unknown mode is an error", yml: "traceModes: [lcoal]\n", wantErr: true}, + // An offline repository whose config broke must not fall back to + // online and upload an offline-only session: the pre-push hook reads + // this, and a silent online default would do exactly that. + {name: "a malformed config is an error, not a silent online", yml: "traceModes: {not: a list}\n", wantErr: true}, + } + + 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)) + + got, err := LoadTraceModesFromYML(dir) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } + + t.Run("no config file is online", func(t *testing.T) { + got, err := LoadTraceModesFromYML(t.TempDir()) + require.NoError(t, err) + assert.Equal(t, TraceModes{Online: true}, got) + }) +} + +func TestTraceModesRoundTrip(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, SaveTraceModesToYML(dir, TraceModes{Online: true, Offline: true})) + require.NoError(t, SaveTracePathToYML(dir, "evidence/sessions")) + + got, err := LoadTraceModesFromYML(dir) + require.NoError(t, err) + assert.Equal(t, TraceModes{Online: true, Offline: true}, got) + 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/config/storage_test.go b/app/cli/internal/trace/config/storage_test.go deleted file mode 100644 index db26679e8..000000000 --- a/app/cli/internal/trace/config/storage_test.go +++ /dev/null @@ -1,158 +0,0 @@ -// -// 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 93ba9c17e..9eca9549c 100644 --- a/app/cli/internal/trace/state/state.go +++ b/app/cli/internal/trace/state/state.go @@ -44,7 +44,7 @@ const ( traceDirCommits = "commits" traceDirSnapshots = "snapshots" traceDirAILines = "ai-lines" - // traceDirLocalSessions holds the evidence sets written by LOCAL trace + // traceDirLocalSessions holds the evidence sets written by the offline 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. @@ -105,7 +105,7 @@ 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 +// LocalSessionsDir returns the default directory the offline trace mode 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. @@ -349,7 +349,7 @@ func (s *Store) staleSessionIDs(unattested map[string]struct{}) map[string]struc // 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 +// sets an offline-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 diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index e22c9f2bb..993104152 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -110,7 +110,7 @@ func TestWipeTraceDir(t *testing.T) { } // TestRemoveTraceDirPreservesLocalSessions pins that RemoveTraceDir keeps the -// local-sessions directory — the durable evidence a LOCAL-mode push or +// local-sessions directory — the durable evidence an offline-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) { @@ -118,7 +118,7 @@ func TestRemoveTraceDirPreservesLocalSessions(t *testing.T) { 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. + // A stored session on disk, as the offline mode 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)) diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 25cd6ac18..0ce11b2c6 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -112,16 +112,61 @@ func setSessionActive(store *state.Store, sessionID string, active bool, log zer } } +// bannerDestination is what the session-start banner knows about where the +// evidence goes. The online fields name the control plane, the offline fields +// the directory on disk. Each one is left empty when it is unknown. +type bannerDestination struct { + dashboardURL, org, project string + tracePath string + // traceRunActive tells that `trace run` drives the session, so the evidence + // is stored when the run finishes, not on a push. + traceRunActive bool +} + // sessionStartBanner is what the developer sees at the top of a traced // session. Its audience includes someone who cloned the repository and never -// ran init, so it says what is happening in plain words and, when we know it, -// where the evidence goes. Chainloop's own vocabulary is deliberately absent: -// "attested" does not tell a newcomer whether something is recorded, -// uploaded, or signed, let alone to where. +// ran init, so it says in plain words which trace modes record the session +// and, when we know it, where the evidence goes. "Attested" is deliberately +// absent: it does not tell a newcomer whether something is recorded, uploaded, +// or signed, let alone to where. // // Each fact is dropped rather than guessed at when it is unknown, so the // banner never promises a destination that was not confirmed. -func sessionStartBanner(dashboardURL, org, project string) string { +func sessionStartBanner(modes config.TraceModes, dest bannerDestination) string { + var banner string + switch { + case modes.Online && modes.Offline: + banner = "Chainloop Trace is recording this session in online and offline modes." + case modes.Offline: + banner = "Chainloop Trace is recording this session in offline mode." + default: + banner = "Chainloop Trace is recording this session in online mode." + } + + if modes.Online { + if where := onlineDestination(dest.dashboardURL, dest.org, dest.project); where != "" { + banner += "\n" + where + } + } + + // R-012: the offline mode says where the evidence lands on disk. + if modes.Offline && dest.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 dest.traceRunActive { + when = "when this run finishes" + } + banner += "\nEvidence will be stored on disk at " + dest.tracePath + " " + when + "." + } + + return banner +} + +// onlineDestination names where the online mode sends the evidence: the +// dashboard, qualified by the organization and project when they are known. +func onlineDestination(dashboardURL, org, project string) string { var identity []string if org != "" { identity = append(identity, "organization: "+org) @@ -142,32 +187,7 @@ func sessionStartBanner(dashboardURL, org, project string) string { where = "Evidence will be sent to " + strings.TrimRight(dashboardURL, "/") } - banner := "Chainloop Trace is recording this session." - if where != "" { - banner += "\n" + where - } - - 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 + return where } // HandleAgentSessionStart handles the agent session-start hook. @@ -206,21 +226,27 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error } if provider.SupportsSessionStartBanner() { - 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), - ) + // An unreadable traceModes falls back to the online banner: the banner + // only informs, and the pre-push hook reports the broken value. + modes, err := config.LoadTraceModesFromYML(repoRoot) + if err != nil { + log.Debug().Err(err).Msg("session-start: could not read traceModes") + modes = config.TraceModes{Online: true} + } + + dest := bannerDestination{traceRunActive: store.IsTraceRunActive()} + // Only the online mode makes the dashboard round trip; the offline mode + // reaches no control plane. + if modes.Online { + dest.dashboardURL = hookDashboardURL(log) + dest.org = config.LoadOrganizationFromYML(repoRoot) + dest.project = repositoryconfig.LoadProjectFromYML(repoRoot) + } + if modes.Offline { + dest.tracePath = ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(repoRoot)) } + + banner := sessionStartBanner(modes, dest) msg.Banner = "\n\n" + banner + "\n" } @@ -774,6 +800,13 @@ func recordRootChanges(store *state.Store, repoRoot string, before map[string]st // .git/chainloop-trace/, which is robust to whatever environment each // agent happens to propagate to its hook subprocesses. // Only meaningful for a store inside a repository; GitDir is empty otherwise. +// offlineModeEnabled reports whether the repository's traceModes include the +// offline mode. An unreadable value counts as not enabled. +func offlineModeEnabled(repoRoot string) bool { + modes, err := config.LoadTraceModesFromYML(repoRoot) + return err == nil && modes.Offline +} + func autoInstallGitHooks(store *state.Store, repoRoot string, log zerolog.Logger) { gitDir := store.GitDir() skipPrePush := store.IsTraceRunActive() @@ -783,10 +816,10 @@ func autoInstallGitHooks(store *state.Store, repoRoot string, log zerolog.Logger } // 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) { + // project name, or the offline mode. An offline-only repository writes no + // projectName, so without the offline check its hooks would never be + // installed in a fresh clone, and every teammate would have to run init. + if repositoryconfig.LoadProjectFromYML(repoRoot) == "" && !offlineModeEnabled(repoRoot) { return } diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index ee3b95651..769d8a2f9 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -127,7 +127,7 @@ func TestHandleAgentSessionStart(t *testing.T) { }) assert.Contains(t, stdout, `"systemMessage"`) - assert.Contains(t, stdout, "Chainloop Trace is recording this session.") + assert.Contains(t, stdout, "Chainloop Trace is recording this session in online mode.") }) t.Run("carries the banner and the spec instruction in one document", func(t *testing.T) { @@ -152,7 +152,7 @@ func TestHandleAgentSessionStart(t *testing.T) { require.NoError(t, dec.Decode(&got)) require.ErrorIs(t, dec.Decode(&map[string]any{}), io.EOF, "stdout must carry exactly one JSON document") - assert.Contains(t, got["systemMessage"], "Chainloop Trace is recording this session.") + assert.Contains(t, got["systemMessage"], "Chainloop Trace is recording this session in online mode.") hookOut, ok := got["hookSpecificOutput"].(map[string]any) require.True(t, ok) @@ -184,7 +184,7 @@ func TestHandleAgentSessionStart(t *testing.T) { hookOut, ok := got["hookSpecificOutput"].(map[string]any) require.True(t, ok) assert.NotContains(t, hookOut, "additionalContext", "the instruction is not worth repeating") - assert.Contains(t, got["systemMessage"], "Chainloop Trace is recording this session.", "the banner still goes out") + assert.Contains(t, got["systemMessage"], "Chainloop Trace is recording this session in online mode.", "the banner still goes out") }) t.Run("ignores malformed stdin", func(t *testing.T) { diff --git a/app/cli/pkg/action/trace_banner_test.go b/app/cli/pkg/action/trace_banner_test.go index 2797a0a8c..3396e3b1e 100644 --- a/app/cli/pkg/action/trace_banner_test.go +++ b/app/cli/pkg/action/trace_banner_test.go @@ -22,6 +22,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" @@ -56,7 +57,7 @@ func (p *bannerProvider) AnnounceSessionStart(msg trace.SessionStartMessage) err // evidence goes without assuming any Chainloop vocabulary. func TestSessionStartBanner(t *testing.T) { const ( - recording = "Chainloop Trace is recording this session." + recording = "Chainloop Trace is recording this session in online mode." testProject = "chainloop-cli" ) @@ -121,7 +122,12 @@ func TestSessionStartBanner(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, sessionStartBanner(tc.dashboardURL, tc.org, tc.project)) + got := sessionStartBanner(config.TraceModes{Online: true}, bannerDestination{ + dashboardURL: tc.dashboardURL, + org: tc.org, + project: tc.project, + }) + assert.Equal(t, tc.want, got) }) } } @@ -181,7 +187,7 @@ func TestSessionStartChannelGate(t *testing.T) { assert.Equal(t, tc.wantSent, p.sysCalls) if tc.wantBanner { - assert.Contains(t, p.last.Banner, "Chainloop Trace is recording this session.") + assert.Contains(t, p.last.Banner, "Chainloop Trace is recording this session in online mode.") } else { assert.Empty(t, p.last.Banner) } diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index ce1292149..ccd2a71b2 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -19,6 +19,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "maps" @@ -528,44 +529,16 @@ type RunTracePushOpts struct { CLIVersion string } -// HandlePrePushHook handles the pre-push git hook. The caller passes a -// returned error through PrePushFailure. -func HandlePrePushHook(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts) error { +// HandlePrePushHook handles the pre-push git hook: it sends the recorded +// sessions to every enabled trace mode. The caller passes a returned error +// through PrePushFailure. +func HandlePrePushHook(ctx context.Context, log zerolog.Logger, opts RunTraceStoreOpts) error { drainPushStdin() - 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 - } + opts.NoRedact = traceNoRedactFromEnv() + _, err := RunTraceStore(ctx, log, opts) - log.Info().Int("sessions", stored).Msgf("AI coding session evidence stored at %s", tracePath) - - return nil + return err } // PrePushFailure decides what a failed pre-push hook does. The managed @@ -588,31 +561,142 @@ func PrePushFailure(err error, requireTrace bool, log zerolog.Logger) error { } // RunTracePush gathers AI-coding-session evidence from the local trace -// state and emits a Chainloop attestation. Shared between the pre-push -// git hook and `chainloop trace run`. -// -//nolint:gocyclo // straight-line orchestration of the pre-push flow; splitting it would only move the branches +// state and emits a Chainloop attestation. It is RunTraceStore with the online +// mode only. func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts) error { + _, err := RunTraceStore(ctx, log, RunTraceStoreOpts{ + Modes: config.TraceModes{Online: true}, + Push: opts, + }) + + return err +} + +// RunTraceStoreOpts configures one run of the trace modes over the recorded +// sessions. +type RunTraceStoreOpts struct { + // Modes selects where the evidence goes. Online attests it to the control + // plane, offline writes it to disk. Both can be set. + Modes config.TraceModes + // Push configures the run. Its Mode and AllowEmpty apply to both modes; the + // rest of it is the online identity and connection, unused offline. + Push RunTracePushOpts + // NoRedact disables secret redaction in the offline store, for a trusted + // local store only (R-004). The online attestation always redacts. + NoRedact bool +} + +// RunTraceStoreResult tells the caller what the offline mode stored. +type RunTraceStoreResult struct { + // StoredLocally is the number of sessions written to disk. + StoredLocally int + // TracePath is the directory the offline mode writes under. It is set + // whenever the offline mode is enabled, also when nothing was stored. + TracePath string +} + +// RunTraceStore sends the recorded sessions to every enabled trace mode. Each +// mode assembles its own copy of the evidence from the same recorded state, +// because the assembly rewrites the evidence it attaches. The offline mode runs +// first: it needs no network, so a control-plane failure cannot cost the local +// copy. +// +// The single-use trace state is pruned only when every enabled mode finished +// its work. A mode that failed leaves the sessions in place, so the next push +// retries it. Storing a session offline again is harmless: the store is +// content-addressed and rewrites the same bytes. +func RunTraceStore(ctx context.Context, log zerolog.Logger, opts RunTraceStoreOpts) (RunTraceStoreResult, error) { + var res RunTraceStoreResult + store, repoRoot, err := state.Locate() if err != nil { - return err + return res, err } - sessionMode := aicodingsession.ResolveMode(opts.Mode) + sessionMode := aicodingsession.ResolveMode(opts.Push.Mode) log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot). - Bool("allow_empty", opts.AllowEmpty).Str("mode", sessionMode).Msg("trace push invoked") + Bool("allow_empty", opts.Push.AllowEmpty).Str("mode", sessionMode). + Str("trace_modes", opts.Modes.String()).Msg("trace store invoked") + + var ( + errs []error + aiCommits []*state.CommitRecord + // done is set when a mode recorded something, and complete is cleared + // when an enabled mode did not finish. Both gate the final prune. + done bool + complete = true + ) + + if opts.Modes.Offline { + res.TracePath = ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(repoRoot)) + + sessions, commits, err := gatherPushSessions(ctx, store, repoRoot, sessionMode, opts.Push.AllowEmpty, log) + aiCommits = commits + + switch { + case err != nil: + errs = append(errs, err) + complete = false + case len(sessions) == 0: + log.Info().Msg("no new AI coding session evidence, nothing stored on disk") + default: + stored, err := storeSessionsLocally(ctx, log, store, sessions, res.TracePath, opts.NoRedact) + res.StoredLocally = stored + + if stored > 0 { + log.Info().Int("sessions", stored).Msgf("AI coding session evidence stored at %s", res.TracePath) + } - sessions, aiCommits, err := gatherPushSessions(ctx, store, repoRoot, sessionMode, opts.AllowEmpty, log) - if err != nil { - return err + switch { + case err != nil: + errs = append(errs, fmt.Errorf("store session evidence locally: %w", err)) + complete = false + case stored != len(sessions): + complete = false + default: + done = true + } + } } - if len(sessions) == 0 { - log.Info().Msg("no new AI coding session evidence, skipping attestation") - return nil + if opts.Modes.Online { + sessions, commits, err := gatherPushSessions(ctx, store, repoRoot, sessionMode, opts.Push.AllowEmpty, log) + aiCommits = commits + + switch { + case err != nil: + errs = append(errs, err) + complete = false + case len(sessions) == 0: + log.Info().Msg("no new AI coding session evidence, skipping attestation") + default: + pushed, err := pushSessions(ctx, log, store, repoRoot, sessions, opts.Push) + switch { + case err != nil: + errs = append(errs, err) + complete = false + case !pushed: + complete = false + default: + done = true + } + } + } + + // Pruning while one mode failed would mark its sessions tracked and wipe + // their state, with no way back for that mode. + if done && complete { + finalizePush(store, repoRoot, aiCommits, log) } + return res, errors.Join(errs...) +} + +// pushSessions attests the assembled sessions to the control plane in one +// attestation. It reports whether an attestation went out: false with no error +// means no session evidence could be added, so nothing was sent. +func pushSessions(ctx context.Context, log zerolog.Logger, store *state.Store, repoRoot string, sessions []sessionEvidence, opts RunTracePushOpts) (bool, error) { // Create attestation using local state file to avoid conflicts with other attestations localStatePath := store.AttestationStatePath() defer func() { _ = os.Remove(localStatePath) }() @@ -620,10 +704,10 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts projectName, organization, workflowName := resolvePushIdentity(repoRoot, opts) if projectName == "" { if opts.IgnoreYAML { - return fmt.Errorf("no project name provided") + return false, fmt.Errorf("no project name provided") } - return fmt.Errorf("no project name found (pass via options or .chainloop.yml)") + return false, fmt.Errorf("no project name found (pass via options or .chainloop.yml)") } log.Debug().Str("project", projectName).Msg("resolved project for trace push") @@ -640,13 +724,13 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts // unless require-trace is on. log.Warn().Err(err).Msg("skipping trace attestation") - return err + return false, err } defer func() { _ = executor.Close() }() attestationID, err := executor.Init(ctx, workflowName, projectName, opts.ProjectVersion) if err != nil { - return fmt.Errorf("attestation init: %w", err) + return false, fmt.Errorf("attestation init: %w", err) } log.Debug().Str("attestation_id", attestationID).Msg("attestation initialized") @@ -659,14 +743,14 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts log.Debug().Msg("no evidence successfully added, resetting attestation") _ = executor.Reset(ctx, AttestationResetTriggerCancelled, "no CHAINLOOP_AI_CODING_SESSION evidence added") - return nil + return false, nil } // Push attestation log.Debug().Msg("pushing attestation") res, err := executor.Push(ctx) if err != nil { - return fmt.Errorf("attestation push: %w", err) + return false, fmt.Errorf("attestation push: %w", err) } // Tell the user where each session landed. The organization comes from the @@ -690,17 +774,13 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts log.Debug().Err(err).Msg("could not record session links for the agent hook") } - log.Debug().Msg("attestation pushed, wiping single-use trace state") - // A later push of each session keeps the spec files recorded here, so // that the limit never drops a spec that is already in the evidence. for sessionID, fileNames := range attestedSpecs { recordPushedSpecs(store, sessionID, fileNames, log) } - finalizePush(store, repoRoot, aiCommits, log) - - return nil + return true, nil } // sessionCommitGroups groups the AI commits by the sessions that contributed to diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index c471f94d2..584b9af20 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -25,11 +25,9 @@ import ( "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" ) @@ -56,74 +54,7 @@ const ( 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: +// ResolveTracePath returns where the offline trace mode 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 { diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index 975780767..d85f2af6f 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -26,6 +26,7 @@ import ( "testing" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "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" @@ -141,20 +142,23 @@ func TestStoreSessionsLocally(t *testing.T) { }) } -// 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) { +// TestRunTraceStoreOfflineNoSessions covers the offline 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 TestRunTraceStoreOfflineNoSessions(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}) + res, err := RunTraceStore(t.Context(), zerolog.Nop(), RunTraceStoreOpts{ + Modes: config.TraceModes{Offline: true}, + Push: RunTracePushOpts{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") + assert.Zero(t, res.StoredLocally, "nothing recorded, nothing stored") + assert.Equal(t, state.NewGitStore(gitDir).LocalSessionsDir(), res.TracePath, "the default path is resolved and returned") - _, statErr := os.Stat(tracePath) + _, statErr := os.Stat(res.TracePath) assert.True(t, os.IsNotExist(statErr), "no output directory is created when there is nothing to store") } @@ -169,32 +173,58 @@ func TestResolveTracePath(t *testing.T) { 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") - }) +// TestOfflineSessionStartBanner covers R-012 and the mode names: the banner +// says which trace modes record the session and where the evidence goes. +func TestOfflineSessionStartBanner(t *testing.T) { + const sessionsDir = "/repo/.git/chainloop-trace/local-sessions" - 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") - }) + testCases := []struct { + name string + modes config.TraceModes + dest bannerDestination + want string + }{ + { + name: "offline names the on-disk destination and the push timing", + modes: config.TraceModes{Offline: true}, + dest: bannerDestination{tracePath: sessionsDir}, + want: "Chainloop Trace is recording this session in offline mode." + + "\nEvidence will be stored on disk at " + sessionsDir + " when you push.", + }, + { + name: "offline says this run finishes under trace run", + modes: config.TraceModes{Offline: true}, + dest: bannerDestination{tracePath: sessionsDir, traceRunActive: true}, + want: "Chainloop Trace is recording this session in offline mode." + + "\nEvidence will be stored on disk at " + sessionsDir + " when this run finishes.", + }, + { + name: "offline drops the destination when unknown", + modes: config.TraceModes{Offline: true}, + want: "Chainloop Trace is recording this session in offline mode.", + }, + { + name: "offline ignores the control-plane identity", + modes: config.TraceModes{Offline: true}, + dest: bannerDestination{dashboardURL: testDashboardURL, org: testOrgName, tracePath: sessionsDir}, + want: "Chainloop Trace is recording this session in offline mode." + + "\nEvidence will be stored on disk at " + sessionsDir + " when you push.", + }, + { + name: "both modes name both destinations", + modes: config.TraceModes{Online: true, Offline: true}, + dest: bannerDestination{dashboardURL: testDashboardURL, org: testOrgName, tracePath: sessionsDir}, + want: "Chainloop Trace is recording this session in online and offline modes." + + "\nEvidence will be sent to " + testDashboardURL + " (organization: " + testOrgName + ")" + + "\nEvidence will be stored on disk at " + sessionsDir + " 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") - }) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, sessionStartBanner(tc.modes, tc.dest)) + }) + } } func readJSONFile(t *testing.T, path string, v any) { @@ -214,3 +244,68 @@ func materialByName(t *testing.T, m localManifest, name string) storedMaterial { require.Failf(t, "material not found", "material %q not found in manifest", name) return storedMaterial{} } + +// TestRunTraceStoreBothModes follows a push with the online and offline modes +// together. The offline mode stores the session on disk first. When the online +// mode then fails, the commit stays untracked, so the next push retries the +// online mode instead of losing the session. When every mode succeeds, the +// commit is marked tracked. +func TestRunTraceStoreBothModes(t *testing.T) { + const sessionID = "5d2b9e7a-1c3f-4a6b-8d9e-0f1a2b3c4d5e" + + home := t.TempDir() + t.Setenv("HOME", home) + + repoDir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + t.Chdir(repoDir) + + projectDir := claudeProjectDir(home, repoDir) + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + tr := transcript{t: t, path: filepath.Join(projectDir, sessionID+".jsonl")} + tr.append(`{"type":"user","uuid":"u1","timestamp":"2026-10-15T10:00:00.000Z","sessionId":"` + sessionID + `","message":{"role":"user","content":"Add a hello world."}}`) + + payload, err := json.Marshal(struct { + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + }{sessionID, "SessionStart", tr.path, repoDir}) + require.NoError(t, err) + withStdin(t, string(payload)) + captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(claude.New(), zerolog.Nop())) }) + + sha := headSHA(t, repoDir) + require.NoError(t, store.SaveCommitRecord(&state.CommitRecord{ + SHA: sha, + Message: "initial", + SessionIDs: []string{sessionID}, + Timestamp: state.NowTimestamp(), + })) + + tracePath := filepath.Join(repoDir, "sessions") + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("tracePath: sessions\n"), 0o600)) + + // The online mode fails before it reaches any control plane: no project + // name is known. + res, err := RunTraceStore(t.Context(), zerolog.Nop(), RunTraceStoreOpts{ + Modes: config.TraceModes{Online: true, Offline: true}, + Push: RunTracePushOpts{IgnoreYAML: true, Mode: aicodingsession.ModeCoding}, + }) + require.ErrorContains(t, err, "no project name") + assert.Equal(t, 1, res.StoredLocally, "the offline mode stored the session before the online mode failed") + assert.Equal(t, tracePath, res.TracePath) + assert.FileExists(t, filepath.Join(tracePath, state.SanitizeID(sessionID), manifestFileName)) + assert.False(t, mustLoadRecord(t, store, sha).Tracked, "a failed mode keeps the commit for the next push") + + // The offline mode alone now finishes every enabled mode, so the commit is + // marked tracked. + res, err = RunTraceStore(t.Context(), zerolog.Nop(), RunTraceStoreOpts{ + Modes: config.TraceModes{Offline: true}, + Push: RunTracePushOpts{Mode: aicodingsession.ModeCoding}, + }) + require.NoError(t, err) + assert.Equal(t, 1, res.StoredLocally) + assert.True(t, mustLoadRecord(t, store, sha).Tracked, "every mode succeeded, so the commit is tracked") +} diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index 5d9c71eda..d9b3b9023 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -77,17 +77,18 @@ type TraceRunOpts struct { ContractRequired bool // ActionOpts is the root command's initialized options, used to build - // the attestation executor. Required unless Local, which reaches no - // control plane. + // the attestation executor. Required when Modes.Online is set; the + // offline mode 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 + // Modes selects where the session goes. Online attests it to the control + // plane after a clean run. Offline writes it to disk at session end, also + // after a failed run, and contacts no control plane. With offline only, the + // identity fields above are unused. + Modes config.TraceModes } // TraceRun wraps a single-shot agent invocation: it cleans any prior @@ -105,10 +106,9 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error return fmt.Errorf("no trace providers selected") } - // 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 { + // Only the online mode reaches the control plane, so offline alone skips + // the credential check and the up-front workflow creation. + if opts.Modes.Online { var authExecOpts []ExecutorOption if opts.Organization != "" { authExecOpts = append(authExecOpts, WithForcedOrganization(opts.Organization)) @@ -193,60 +193,52 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error 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) - } + // The offline 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 online mode, in contrast, + // attests only a clean run. Either way the agent's own exit code is + // preserved. + modes := opts.Modes + if runErr != nil { + modes.Online = false + } - return nil + if !modes.Online && !modes.Offline { + return subprocessError(opts.Command[0], runErr) } + log.Debug().Str("trace_modes", modes.String()).Msg("wrapped command finished; recording session") + + _, storeErr := RunTraceStore(ctx, log, RunTraceStoreOpts{ + Modes: modes, + NoRedact: traceNoRedactFromEnv(), + Push: RunTracePushOpts{ + AllowEmpty: true, + ProjectName: opts.ProjectName, + Organization: opts.Organization, + WorkflowName: opts.WorkflowName, + ProjectVersion: opts.ProjectVersion, + IgnoreYAML: true, + SkipAgentNotification: true, + Mode: aicodingsession.ModeGeneric, + ActionOpts: opts.ActionOpts, + CLIVersion: opts.CLIVersion, + }, + }) + + // 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 record the evidence is never reported as success. if runErr != nil { + if storeErr != nil { + log.Warn().Err(storeErr).Msg("could not store session evidence") + } + return subprocessError(opts.Command[0], runErr) } - log.Debug().Msg("wrapped command completed; attesting session") - - return RunTracePush(ctx, log, RunTracePushOpts{ - AllowEmpty: true, - ProjectName: opts.ProjectName, - Organization: opts.Organization, - WorkflowName: opts.WorkflowName, - ProjectVersion: opts.ProjectVersion, - IgnoreYAML: true, - SkipAgentNotification: true, - Mode: aicodingsession.ModeGeneric, - ActionOpts: opts.ActionOpts, - CLIVersion: opts.CLIVersion, - }) + return storeErr } // subprocessError maps the error from running the wrapped command into the From da0d27598d1c8a10585d022ad174e8d58b183b38 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 13:08:57 +0200 Subject: [PATCH 09/19] Revert "feat(trace): online and offline trace modes that can run together" This reverts commit db6b41939b3db183661b5ec42b637aad3e1be952. Signed-off-by: Jose I. Paris Assisted-by: Claude Code --- app/cli/cmd/root.go | 6 +- app/cli/cmd/telemetry_report.go | 4 +- app/cli/cmd/trace_hook.go | 32 +- app/cli/cmd/trace_init.go | 383 ++++++++---------- app/cli/cmd/trace_init_local_test.go | 236 +++++++++++ app/cli/cmd/trace_init_modes_test.go | 290 ------------- app/cli/cmd/trace_init_test.go | 23 +- app/cli/cmd/trace_run.go | 35 +- app/cli/documentation/cli-reference.md | 37 +- app/cli/internal/repositoryconfig/config.go | 13 +- app/cli/internal/trace/README.md | 23 +- app/cli/internal/trace/config/config.go | 105 ++--- app/cli/internal/trace/config/modes_test.go | 165 -------- app/cli/internal/trace/config/storage_test.go | 158 ++++++++ app/cli/internal/trace/state/state.go | 6 +- app/cli/internal/trace/state/state_test.go | 4 +- app/cli/pkg/action/trace_agent_hook.go | 131 +++--- app/cli/pkg/action/trace_agent_hook_test.go | 6 +- app/cli/pkg/action/trace_banner_test.go | 12 +- app/cli/pkg/action/trace_hook_handler.go | 198 +++------ app/cli/pkg/action/trace_local_store.go | 71 +++- app/cli/pkg/action/trace_local_store_test.go | 161 ++------ app/cli/pkg/action/trace_run.go | 106 ++--- 23 files changed, 952 insertions(+), 1253 deletions(-) create mode 100644 app/cli/cmd/trace_init_local_test.go delete mode 100644 app/cli/cmd/trace_init_modes_test.go delete mode 100644 app/cli/internal/trace/config/modes_test.go create mode 100644 app/cli/internal/trace/config/storage_test.go diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 7629fcf9a..4e2b18ea1 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -64,7 +64,7 @@ const ( skipActionOptsInit = "skipActionOptsInit" // 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 an offline-only run opens no connection, + // `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" @@ -158,7 +158,7 @@ func handleOrganizationExecutionError(executed *cobra.Command, err error) error // 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 the offline mode alone never calls it, so it makes no +// 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() { @@ -278,7 +278,7 @@ Command reference: ` + cliReferenceURL, // 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 an offline-only repository makes no control-plane call at all. + // branch, so a LOCAL-mode repository makes no control-plane call at all. if cmd.Annotations[skipActionOptsInit] == trueString { return nil } diff --git a/app/cli/cmd/telemetry_report.go b/app/cli/cmd/telemetry_report.go index 9f205c4a9..9b8d52cd9 100644 --- a/app/cli/cmd/telemetry_report.go +++ b/app/cli/cmd/telemetry_report.go @@ -121,8 +121,8 @@ func telemetryIdentity() *token.ParsedToken { // Beyond that, commands annotated skipActionOptsInit are the local-only ones, such as the // 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 an offline-only run opens no connection, yet an online run of -// it must still be reported as it was before the offline mode existed. +// 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 diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go index dd2664444..f4974cad7 100644 --- a/app/cli/cmd/trace_hook.go +++ b/app/cli/cmd/trace_hook.go @@ -123,9 +123,9 @@ func newTraceHookGitPrePushCmd() *cobra.Command { // action.PrePushFailure in Execute. // // skipActionOptsInit keeps root from building the control-plane - // connection (and probing the current org) up front: an offline-only + // 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 online mode is enabled. + // lazily below, only when the storage mode is CONTROL_PLANE. Annotations: map[string]string{ prePushHookAnnotation: trueString, skipActionOptsInit: trueString, @@ -135,27 +135,25 @@ func newTraceHookGitPrePushCmd() *cobra.Command { // The hook runs at the repository root, so "." resolves the nearest // .chainloop.yml the same way the push identity does. - modes, err := config.LoadTraceModesFromYML(".") + mode, err := config.LoadTraceStorageFromYML(".") if err != nil { return err } - // Online: build ActionOpts lazily, since this command is - // skipActionOptsInit and root did not. The offline mode alone - // reaches no control plane. - if modes.Online { - if err := ensureActionOpts(cmd); err != nil { - return err - } + if mode == config.TraceStorageLocal { + return action.HandlePrePushHookLocal(cmd.Context(), logger) } - return action.HandlePrePushHook(cmd.Context(), logger, action.RunTraceStoreOpts{ - Modes: modes, - Push: action.RunTracePushOpts{ - ActionOpts: ActionOpts, - CLIVersion: Version, - Mode: aicodingsession.ModeCoding, - }, + // 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, + Mode: aicodingsession.ModeCoding, }) }, } diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 155b0ec93..b1069c69f 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -35,8 +35,6 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "github.com/spf13/cobra" "github.com/spf13/viper" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) // newTraceInitCmd creates the trace init subcommand. @@ -44,8 +42,7 @@ func newTraceInitCmd() *cobra.Command { var ( project string contract string - onlineFlag bool - offlineFlag bool + localFlag bool tracePathFlag string claudeFlag bool cursorFlag bool @@ -56,8 +53,8 @@ func newTraceInitCmd() *cobra.Command { 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; the offline mode alone never does, so it - // reaches no control plane at all (R-008). + // 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 @@ -83,49 +80,32 @@ 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. -The trace modes select where a session's evidence goes. --online (the default) -attests it to the Chainloop control plane. --offline stores it on disk, under -.git/chainloop-trace/local-sessions or the --trace-path directory, and needs no -login and no control plane. Pass both to do both. With no mode flag, init keeps -the modes in .chainloop.yml, or uses --online on a first run. The modes are -saved to .chainloop.yml as traceModes, so every clone records the same way. - -With --offline alone, init installs the same hooks but creates no workflow, -needs no login, and reaches no control plane. --contract, --workflow, --org and ---project are control-plane settings and need --online; --require-trace does -not. - -Run init once per repository. In a clone of an initialized repository, the -first traced session installs the git hooks.`, +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 } - // An explicit mode flag overrides the modes in .chainloop.yml, also - // an unreadable value. With no mode flag, an unreadable value is an - // error before anything is installed: every pre-push would fail on - // it, so init must not report success over it. - existingModes, existingErr := config.LoadTraceModesFromYML(repoRoot) - modes, saveModes, err := resolveTraceModes(traceModeFlags{ - online: onlineFlag, - onlineSet: cmd.Flags().Changed("online"), - offline: offlineFlag, - offlineSet: cmd.Flags().Changed("offline"), - }, existingModes, existingErr) - 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 } - // The offline mode alone 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 !modes.Online { + // 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 without --online: the offline mode contacts no control plane and creates no workflow", name) + return fmt.Errorf("--%s cannot be used with --local: local mode contacts no control plane and creates no workflow", name) } } } @@ -134,24 +114,38 @@ first traced session installs the git hooks.`, if err != nil { return err } - cfg.modes = modes - cfg.saveModes = saveModes + cfg.local = localFlag cfg.tracePath = tracePathFlag - cfg.saveTracePath = 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) - // The online mode fills in the identity and opens an executor, - // building the connection lazily (the command is - // skipActionOptsInit). The offline mode alone resolves no identity - // and opens no executor. + // 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 modes.Online { + if !localFlag { if err := ensureActionOpts(cmd); err != nil { - return withOfflineHint(err) + return err } executor, err = resolveTraceIdentity(cmd.Context(), cfg, repoRoot) if err != nil { - return withOfflineHint(stopIfAborted(err)) + return stopIfAborted(err) } defer func() { _ = executor.Close() }() } @@ -171,10 +165,10 @@ first traced session installs the git hooks.`, return fmt.Errorf("no trace providers selected") } - // The offline mode has no control plane to create the workflow in. - if modes.Online { + // Local mode has no control plane to create the workflow in. + if !localFlag { if err := ensureTraceInitWorkflow(cmd.Context(), executor, cfg, contract); err != nil { - return withOfflineHint(err) + return err } } @@ -238,10 +232,6 @@ first traced session installs the git hooks.`, // local, but a failure part-way through it would leave a repository // half set up: harness hooks calling a configuration that is not // there, or a configuration with nothing recording into it. - // Read before writeTraceInitState marks the repository initialized: - // a change of modes is news only to a repository that was set up. - wasInitialized := state.NewGitStore(gitDir).IsTraceInitialized() - if err := writeTraceInitState(cfg, repoRoot, gitDir); err != nil { for _, snapshot := range before { if rerr := snapshot.restore(); rerr != nil { @@ -262,29 +252,28 @@ first traced session installs the git hooks.`, // 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. - summary := traceInitSummary{modes: modes, harnesses: providerNames(installed)} - if modes.Online { - // 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. - summary.organization = cmp.Or(cfg.organization, viper.GetString(confOptions.organization.viperKey)) - summary.project = cfg.project - summary.workflow = cfg.workflow - } - if modes.Offline { - summary.sessionsPath = traceSessionsPathForDisplay(repoRoot, gitDir, workDir) + if cfg.local { + writeTraceInitLocalSummary(os.Stdout, repoRoot, providerNames(installed)) + writeTraceLocalNextSteps(os.Stdout, repoRoot, workDir, installed) + + return nil } - writeTraceInitSummary(os.Stdout, summary) + // 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)) - // A change of modes in a repository that was already set up changes + writeTraceInitSummary(os.Stdout, organization, cfg.project, cfg.workflow, providerNames(installed)) + + // A control-plane init that cleared a previous LOCAL policy changed // how every push behaves, so say it rather than leave it to a debug // log the user never sees. - if saveModes && existingErr == nil && (wasInitialized || existingModes != (config.TraceModes{Online: true})) { - writeTraceModesChangedNote(os.Stdout, existingModes, modes, config.ChainloopYMLName(repoRoot)) + if existingMode == config.TraceStorageLocal { + writeTraceStorageSwitchedNote(os.Stdout, config.ChainloopYMLName(repoRoot)) } - writeTraceNextSteps(os.Stdout, modes, repoRoot, workDir, installed) + writeTraceNextSteps(os.Stdout, repoRoot, workDir, installed) return nil }, @@ -292,9 +281,8 @@ first traced session installs the git hooks.`, cmd.Flags().StringVar(&project, "project", "", "chainloop project name") cmd.Flags().StringVar(&contract, "contract", "", traceContractFlagDesc) - cmd.Flags().BoolVar(&onlineFlag, "online", false, "attest sessions to the Chainloop control plane (the default when no mode flag is set and .chainloop.yml has no traceModes)") - cmd.Flags().BoolVar(&offlineFlag, "offline", false, "store sessions on disk, with no control plane, login or workflow; combine with --online to do both") - cmd.Flags().StringVar(&tracePathFlag, "trace-path", "", "where the offline mode stores sessions (default: .git/chainloop-trace/local-sessions)") + 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 a required trace is missing") cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)") @@ -308,82 +296,35 @@ first traced session installs the git hooks.`, // done with it, which is more than belongs in a command's own output. const traceDocsURL = "https://docs.chainloop.dev/ai-sessions" -// traceInitSummary is what init reports it set up. The online fields are empty -// for the offline mode alone, and sessionsPath is empty without the offline -// mode. -type traceInitSummary struct { - modes config.TraceModes - organization, project, workflow string - sessionsPath string - harnesses []string -} - // writeTraceInitSummary reports what the repository was set up with. It goes to // stdout rather than through the logger because it is the command's result, // not a note about something that happened on the way there. -func writeTraceInitSummary(w io.Writer, s traceInitSummary) { +func writeTraceInitSummary(w io.Writer, organization, project, workflow string, harnesses []string) { fmt.Fprint(w, "\nCongratulations, your repository is initialized\n\n") - if s.modes.Online { - fmt.Fprintf(w, " modes %s\n", s.modes) - - // Empty only when the CLI has no organization configured either, - // which leaves nothing truthful to name. - if s.organization != "" { - fmt.Fprintf(w, " organization %s\n", s.organization) - } - - fmt.Fprintf(w, " project %s\n", s.project) - fmt.Fprintf(w, " workflow %s\n", s.workflow) - } else { - fmt.Fprintf(w, " modes %s (no control plane)\n", s.modes) - } - - if s.modes.Offline { - fmt.Fprintf(w, " sessions stored on disk under %s on push\n", s.sessionsPath) - } - - fmt.Fprintf(w, " harnesses %s\n", strings.Join(s.harnesses, ", ")) - - // The offline mode alone leaves the control plane out. Say how to bring - // it in, so the choice does not look final. - if !s.modes.Online { - fmt.Fprint(w, "\nTo also send sessions to a Chainloop control plane later, run:\n chainloop trace init --online --offline\n") - } -} - -// writeTraceModesChangedNote tells the user that this init changed the trace -// modes of a repository that was already set up. The change affects how every -// push behaves, so it is stated plainly here rather than left to a debug log, -// and the user is reminded to commit it like any other repository policy -// change. -func writeTraceModesChangedNote(w io.Writer, from, to config.TraceModes, ymlName string) { - fmt.Fprintf(w, "\nTrace modes changed from %s to %s. Commit %s so every clone\nrecords the same way.\n", from, to, ymlName) -} - -// traceSessionsPathForDisplay names where the offline mode stores sessions, -// relative to where the user ran init when that is shorter to read. -func traceSessionsPathForDisplay(repoRoot, gitDir, workDir string) string { - path := action.ResolveTracePath(repoRoot, state.NewGitStore(gitDir), config.LoadTracePathFromYML(repoRoot)) - if rel, err := filepath.Rel(workDir, path); err == nil { - return rel + // Empty only when the CLI has no organization configured either, which + // leaves nothing truthful to name. + if organization != "" { + fmt.Fprintf(w, " organization %s\n", organization) } - return path + fmt.Fprintf(w, " project %s\n", project) + fmt.Fprintf(w, " workflow %s\n", workflow) + fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) } // writeTraceNextSteps says what is left for the user to do. Committing comes // first: what init wrote into the repository is shared, and until it is // committed this setup exists in one working copy only. // -// With the online mode it stops short of promising that a teammate who pulls -// is set up: a teammate needs their own login. The offline mode needs nothing -// of the kind, and the first traced session in a clone installs the git hooks, -// so a teammate does not run init at all. +// It stops short of promising that a teammate who pulls is set up: the git +// hooks live in .git/hooks, which git does not carry between clones, so they +// still have to run init themselves. What they gain is having nothing to answer +// when they do. // // workDir is where the user ran the command, which is what `git add` resolves // its arguments against; it is not always the repository root. -func writeTraceNextSteps(w io.Writer, modes config.TraceModes, repoRoot, workDir string, installed []trace.Provider) { +func writeTraceNextSteps(w io.Writer, repoRoot, workDir string, installed []trace.Provider) { files := make([]string, 0, len(installed)+1) add := func(absolute string) { // An absolute path stages just as well, so it is the fallback for the @@ -402,35 +343,82 @@ func writeTraceNextSteps(w io.Writer, modes config.TraceModes, repoRoot, workDir add(p.SettingsFile(repoRoot)) } - commit := "Commit these files. Teammates then only need to run chainloop trace init:" - if !modes.Online { - commit = "Commit these files so every clone records the same way:" + fmt.Fprintf(w, ` +What's next + + 1. Commit these files. Teammates then only need to run chainloop trace init: + git add %s + 2. Start %s and write some code + 3. Commit and push as usual. The AI coding sessions behind those commits are + recorded and stored automatically + +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") } - var push string - switch { - case modes.Online && modes.Offline: - push = `The AI coding sessions behind those commits are - recorded in Chainloop and written to disk automatically; the push tells - you where` - case modes.Offline: - push = `The session evidence is written to disk, with no - control plane; the push tells you where` - default: - push = `The AI coding sessions behind those commits are - recorded and stored automatically` + fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) +} + +// writeTraceStorageSwitchedNote tells the user that this control-plane init +// cleared a previous local-mode policy. The switch changes how every push +// behaves, so it is stated plainly here rather than left to a debug log, and the +// user is reminded to commit it like any other repository policy change. +func writeTraceStorageSwitchedNote(w io.Writer, ymlName string) { + fmt.Fprintf(w, "\nThis repository was in local mode. Trace now attests sessions to the control\nplane. Commit %s so every clone switches too.\n", ymlName) +} + +// 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. %s + 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. %s + 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 -`, commit, strings.Join(files, " "), joinWithOr(providerNames(installed)), push, traceDocsURL) +`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL) } // providerNames names the harnesses, in the order they were installed. @@ -619,70 +607,37 @@ type traceInitConfig struct { organization string requireTrace bool - // modes are the trace modes of the repository after this run, written - // when saveModes is set. tracePath is the optional override for where the - // offline mode stores sessions. - modes config.TraceModes + // 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 - saveModes, saveTracePath bool + saveTraceStorage, saveTracePath bool } -// traceModeFlags are the --online and --offline flags, and whether the user -// passed each one. -type traceModeFlags struct { - online, onlineSet bool - offline, offlineSet bool -} - -// resolveTraceModes decides the trace modes init sets up and whether it writes -// them to .chainloop.yml. A mode flag sets the modes outright: the passed flags -// are the modes. With no mode flag, init keeps the existing modes, which are -// online only when .chainloop.yml has none. The modes are written only when -// they change, so a fresh online repository keeps its config minimal. -// existingErr is the error from reading the existing modes: a mode flag -// overrides the unreadable value, and with no mode flag it is returned. -func resolveTraceModes(flags traceModeFlags, existing config.TraceModes, existingErr error) (config.TraceModes, bool, error) { - if !flags.onlineSet && !flags.offlineSet { - if existingErr != nil { - return config.TraceModes{}, false, fmt.Errorf("existing .chainloop.yml: %w", existingErr) - } - - return existing, false, nil - } - - modes := config.TraceModes{Online: flags.online, Offline: flags.offline} - if !modes.Online && !modes.Offline { - return config.TraceModes{}, false, errors.New("set at least one trace mode: --online, --offline, or both") - } - - return modes, existingErr != nil || modes != existing, nil -} - -// offlineHint is added to an online init error when the control plane cannot -// be used, because the offline mode needs no control plane. -const offlineHint = `To trace without a Chainloop control plane, run "chainloop trace init --offline"` - -// withOfflineHint adds offlineHint to an error that tells the control plane is -// not available: the credentials are missing or rejected, or the control plane -// cannot be reached. Any other error is returned unchanged. -func withOfflineHint(err error) error { - if err == nil { - return nil - } - - if msg, ok := action.AuthErrorMessage(err); ok { - return fmt.Errorf("%s\n%s", msg, offlineHint) - } - - if st, ok := status.FromError(err); ok && st.Code() == codes.Unavailable { - return fmt.Errorf("%w\n%s", err, offlineHint) +// 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 } - - return err } // resolveTraceInitConfig reads the trace settings from the flags, falling back @@ -740,13 +695,13 @@ 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 { - // Trace modes first: the offline mode is what makes the push hook store - // on disk, so it must land even if nothing else is written. - if c.saveModes { - if err := config.SaveTraceModesToYML(repoRoot, c.modes); err != nil { + // 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("traceModes", c.modes.String()).Msg("traceModes saved to .chainloop.yml") + logger.Debug().Str("traceStorage", c.traceStorage).Msg("traceStorage saved to .chainloop.yml") } if c.saveTracePath { 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..06df0c809 --- /dev/null +++ b/app/cli/cmd/trace_init_local_test.go @@ -0,0 +1,236 @@ +// +// 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 TestWriteTraceStorageSwitchedNote(t *testing.T) { + out := &bytes.Buffer{} + writeTraceStorageSwitchedNote(out, ".chainloop.yml") + + got := out.String() + assert.Contains(t, got, "was in local mode") + assert.Contains(t, got, "control") + assert.Contains(t, got, ".chainloop.yml") +} + +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_init_modes_test.go b/app/cli/cmd/trace_init_modes_test.go deleted file mode 100644 index d39fe6883..000000000 --- a/app/cli/cmd/trace_init_modes_test.go +++ /dev/null @@ -1,290 +0,0 @@ -// -// 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" - "errors" - "fmt" - "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" - jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -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() -} - -// TestTraceInitOffline covers R-008/R-011: `trace init --offline` sets the -// repository up for the offline mode with no control plane, writing -// traceModes: [offline] and no control-plane identity. -func TestTraceInitOffline(t *testing.T) { - t.Run("writes the offline mode and no identity, installs hooks", func(t *testing.T) { - repoDir := t.TempDir() - gitInit(t, repoDir) - - require.NoError(t, runTraceInit(t, repoDir, "--offline", "--claude")) - - modes, err := config.LoadTraceModesFromYML(repoDir) - require.NoError(t, err) - assert.Equal(t, config.TraceModes{Offline: true}, modes) - - data, err := os.ReadFile(filepath.Join(repoDir, chainloopYMLName)) - require.NoError(t, err) - got := string(data) - 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 is written with --offline", func(t *testing.T) { - repoDir := t.TempDir() - gitInit(t, repoDir) - - require.NoError(t, runTraceInit(t, repoDir, "--offline", "--trace-path", "evidence/sessions", "--claude")) - - assert.Equal(t, "evidence/sessions", config.LoadTracePathFromYML(repoDir)) - }) - - 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, "--offline", "--require-trace", "--claude")) - - assert.True(t, config.LoadTraceRequiredFromYML(repoDir)) - }) - - t.Run("control-plane flags are rejected without --online", 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{"--offline", "--claude"}, flag...) - err := runTraceInit(t, repoDir, args...) - assert.ErrorContains(t, err, "cannot be used without --online", "flag %v", flag) - } - }) - - t.Run("a second run with no mode flag keeps the offline mode", func(t *testing.T) { - repoDir := t.TempDir() - gitInit(t, repoDir) - - require.NoError(t, runTraceInit(t, repoDir, "--offline", "--claude")) - // No control plane is reachable here, so a run that fell back to the - // online default would fail. - require.NoError(t, runTraceInit(t, repoDir, "--claude")) - - modes, err := config.LoadTraceModesFromYML(repoDir) - require.NoError(t, err) - assert.Equal(t, config.TraceModes{Offline: true}, modes) - }) -} - -// TestTraceInitInvalidModes covers the guard that an init over an invalid -// traceModes fails before installing hooks, so init never reports success for -// a repository whose every pre-push would then error, while an explicit mode -// flag overrides the bad value. -func TestTraceInitInvalidModes(t *testing.T) { - t.Run("init with no mode flag over an invalid traceModes errors before install", func(t *testing.T) { - repoDir := t.TempDir() - gitInit(t, repoDir) - require.NoError(t, os.WriteFile(filepath.Join(repoDir, chainloopYMLName), - []byte("traceModes: [bogus]\n"), 0600)) - - err := runTraceInit(t, repoDir, "--claude") - require.Error(t, err) - assert.Contains(t, err.Error(), "traceModes") - - // 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("--offline overrides an invalid traceModes", func(t *testing.T) { - repoDir := t.TempDir() - gitInit(t, repoDir) - require.NoError(t, os.WriteFile(filepath.Join(repoDir, chainloopYMLName), - []byte("traceModes: [bogus]\n"), 0600)) - - require.NoError(t, runTraceInit(t, repoDir, "--offline", "--claude")) - - modes, err := config.LoadTraceModesFromYML(repoDir) - require.NoError(t, err) - assert.Equal(t, config.TraceModes{Offline: true}, modes) - }) -} - -func TestResolveTraceModes(t *testing.T) { - online := config.TraceModes{Online: true} - offline := config.TraceModes{Offline: true} - both := config.TraceModes{Online: true, Offline: true} - errBroken := errors.New("broken traceModes") - - testCases := []struct { - name string - flags traceModeFlags - existing config.TraceModes - existingErr error - want config.TraceModes - wantSave bool - wantErr bool - }{ - {name: "no flag on a fresh repository is online and writes nothing", existing: online, want: online}, - {name: "no flag keeps an existing offline mode", existing: offline, want: offline}, - {name: "no flag keeps existing both modes", existing: both, want: both}, - {name: "no flag over a broken value is an error", existingErr: errBroken, wantErr: true}, - {name: "--offline alone is offline only", flags: traceModeFlags{offline: true, offlineSet: true}, existing: online, want: offline, wantSave: true}, - {name: "--offline again writes nothing", flags: traceModeFlags{offline: true, offlineSet: true}, existing: offline, want: offline}, - {name: "--online --offline is both", flags: traceModeFlags{online: true, onlineSet: true, offline: true, offlineSet: true}, existing: online, want: both, wantSave: true}, - {name: "--online over offline switches to online", flags: traceModeFlags{online: true, onlineSet: true}, existing: offline, want: online, wantSave: true}, - {name: "--online on a fresh repository writes nothing", flags: traceModeFlags{online: true, onlineSet: true}, existing: online, want: online}, - {name: "a flag overrides a broken value", flags: traceModeFlags{offline: true, offlineSet: true}, existingErr: errBroken, want: offline, wantSave: true}, - {name: "every flag false is an error", flags: traceModeFlags{onlineSet: true}, existing: online, wantErr: true}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - got, save, err := resolveTraceModes(tc.flags, tc.existing, tc.existingErr) - if tc.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tc.want, got) - assert.Equal(t, tc.wantSave, save) - }) - } -} - -func TestWriteTraceInitSummaryModes(t *testing.T) { - t.Run("offline only names no control plane and how to connect one later", func(t *testing.T) { - out := &bytes.Buffer{} - writeTraceInitSummary(out, traceInitSummary{ - modes: config.TraceModes{Offline: true}, - sessionsPath: ".git/chainloop-trace/local-sessions", - harnesses: []string{providerClaudeCode, "cursor"}, //nolint:goconst // provider wire name, kept literal like the other provider tests - }) - - got := out.String() - assert.Contains(t, got, "offline (no control plane)") - assert.Contains(t, got, ".git/chainloop-trace/local-sessions") - assert.Contains(t, got, "claude-code, cursor") - assert.NotContains(t, got, "organization") - assert.Contains(t, got, "chainloop trace init --online --offline") - }) - - t.Run("both modes name the control plane and the disk", func(t *testing.T) { - out := &bytes.Buffer{} - writeTraceInitSummary(out, traceInitSummary{ - modes: config.TraceModes{Online: true, Offline: true}, - organization: "acme", - project: "backend-api", - workflow: defaultTraceWorkflow, - sessionsPath: ".git/chainloop-trace/local-sessions", - harnesses: []string{providerClaudeCode}, - }) - - got := out.String() - assert.Contains(t, got, "online, offline") - assert.Contains(t, got, "acme") - assert.Contains(t, got, "backend-api") - assert.Contains(t, got, ".git/chainloop-trace/local-sessions") - assert.NotContains(t, got, "--online --offline", "a repository with the online mode needs no hint to connect") - }) -} - -func TestWriteTraceNextStepsOffline(t *testing.T) { - repoRoot := t.TempDir() - out := &bytes.Buffer{} - writeTraceNextSteps(out, config.TraceModes{Offline: true}, 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 first session in a clone installs the git hooks, so a teammate does - // not run init again. - assert.NotContains(t, got, "chainloop trace init") -} - -func TestWriteTraceModesChangedNote(t *testing.T) { - out := &bytes.Buffer{} - writeTraceModesChangedNote(out, config.TraceModes{Offline: true}, config.TraceModes{Online: true, Offline: true}, ".chainloop.yml") - - got := out.String() - assert.Contains(t, got, "from offline to online, offline") - assert.Contains(t, got, ".chainloop.yml") -} - -// TestWithOfflineHint covers the hint an online init adds when the control -// plane rejects the credentials: the offline mode needs none. -func TestWithOfflineHint(t *testing.T) { - t.Run("an authentication error suggests the offline mode", func(t *testing.T) { - authErr := fmt.Errorf("resolve organization: %w", - status.Error(codes.Unauthenticated, jwtMiddleware.ErrMissingJwtToken.Message)) - - got := withOfflineHint(authErr) - require.Error(t, got) - assert.Contains(t, got.Error(), `please run "chainloop auth login"`) - assert.Contains(t, got.Error(), "chainloop trace init --offline") - }) - - t.Run("an unreachable control plane suggests the offline mode", func(t *testing.T) { - got := withOfflineHint(status.Error(codes.Unavailable, "connection refused")) - require.Error(t, got) - assert.Contains(t, got.Error(), "connection refused") - assert.Contains(t, got.Error(), "chainloop trace init --offline") - }) - - t.Run("any other error is unchanged", func(t *testing.T) { - other := errors.New("boom") - assert.Equal(t, other, withOfflineHint(other)) - assert.NoError(t, withOfflineHint(nil)) - }) -} diff --git a/app/cli/cmd/trace_init_test.go b/app/cli/cmd/trace_init_test.go index d336159a0..2b16b373f 100644 --- a/app/cli/cmd/trace_init_test.go +++ b/app/cli/cmd/trace_init_test.go @@ -22,7 +22,6 @@ import ( "slices" "testing" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -342,7 +341,7 @@ func TestWriteTraceNextSteps(t *testing.T) { t.Run(tc.name, func(t *testing.T) { out := &bytes.Buffer{} repoRoot := t.TempDir() - writeTraceNextSteps(out, config.TraceModes{Online: true}, repoRoot, repoRoot, providers.ByNames(tc.providers)) + writeTraceNextSteps(out, repoRoot, repoRoot, providers.ByNames(tc.providers)) got := out.String() for _, f := range tc.wantFiles { @@ -364,7 +363,7 @@ func TestWriteTraceNextStepsFromSubdirectory(t *testing.T) { require.NoError(t, os.MkdirAll(workDir, 0750)) out := &bytes.Buffer{} - writeTraceNextSteps(out, config.TraceModes{Online: true}, repoRoot, workDir, providers.ByNames([]string{providerClaudeCode})) + writeTraceNextSteps(out, repoRoot, workDir, providers.ByNames([]string{providerClaudeCode})) got := out.String() assert.Contains(t, got, filepath.Join("..", "..", chainloopYMLName)) @@ -374,13 +373,8 @@ func TestWriteTraceNextStepsFromSubdirectory(t *testing.T) { func TestWriteTraceInitSummary(t *testing.T) { t.Run("every value is reported", func(t *testing.T) { out := &bytes.Buffer{} - writeTraceInitSummary(out, traceInitSummary{ - modes: config.TraceModes{Online: true}, - organization: "acme", - project: "backend-api", - workflow: defaultTraceWorkflow, - harnesses: []string{providerClaudeCode, "cursor"}, - }) + writeTraceInitSummary(out, "acme", "backend-api", defaultTraceWorkflow, + []string{providerClaudeCode, "cursor"}) got := out.String() assert.Contains(t, got, "your repository is initialized") @@ -388,18 +382,11 @@ func TestWriteTraceInitSummary(t *testing.T) { assert.Contains(t, got, "backend-api") assert.Contains(t, got, defaultTraceWorkflow) assert.Contains(t, got, "claude-code, cursor") - assert.Contains(t, got, "online") - assert.NotContains(t, got, "offline") }) t.Run("no organization anywhere leaves the line out rather than guessing", func(t *testing.T) { out := &bytes.Buffer{} - writeTraceInitSummary(out, traceInitSummary{ - modes: config.TraceModes{Online: true}, - project: "backend-api", - workflow: defaultTraceWorkflow, - harnesses: []string{providerClaudeCode}, - }) + writeTraceInitSummary(out, "", "backend-api", defaultTraceWorkflow, []string{providerClaudeCode}) assert.NotContains(t, out.String(), "organization") }) diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 0523ead5c..ef349ecfb 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -34,12 +34,11 @@ Sessions without git commits are still attested. 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 -traceModes, though: in an offline repository (traceModes: [offline]) the +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. With both modes, -the session is written to disk and attested. Either way it +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 offline session evidence, so a session never leaks setup into +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 @@ -65,7 +64,7 @@ func newTraceRunCmd() *cobra.Command { Args: cobra.MinimumNArgs(1), // skipActionOptsInit keeps root from building the control-plane // connection up front; the attestation path builds it lazily with - // ensureActionOpts, and an offline-only run never does (R-005). + // 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 @@ -97,13 +96,14 @@ func newTraceRunCmd() *cobra.Command { store = state.NewGitStore(gitDir) } - // The trace modes are a repository policy, read even though trace - // run keeps its identity flag-only: an offline-only repository stores to + // 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. - modes, err := config.LoadTraceModesFromYML(repoRoot) + storageMode, err := config.LoadTraceStorageFromYML(repoRoot) if err != nil { return err } + local := storageMode == config.TraceStorageLocal var ( organization string @@ -111,11 +111,10 @@ func newTraceRunCmd() *cobra.Command { contractRequired bool ) - if modes.Online { - // An online run is reported in telemetry as it was before the - // offline mode made this command skipActionOptsInit. An - // offline-only run is not: it leaves the annotation unset and - // stays excluded. + 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, @@ -142,7 +141,7 @@ func newTraceRunCmd() *cobra.Command { contractName, contractRequired = config.ResolveContract(contractFlag) // Build ActionOpts lazily (this command is skipActionOptsInit), - // so an offline-only run never opens a control-plane connection. + // so a LOCAL run never opens a control-plane connection. if err := ensureActionOpts(cmd); err != nil { return err } @@ -161,20 +160,20 @@ func newTraceRunCmd() *cobra.Command { ContractRequired: contractRequired, ActionOpts: ActionOpts, CLIVersion: Version, - Modes: modes, + Local: local, }) }, } - cmd.Flags().StringVar(&projectFlag, "project", "", "chainloop project name (required unless the repository is offline only; .chainloop.yml identity is ignored)") - cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name used for trace attestations (required unless the repository is offline only; .chainloop.yml identity 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") - // project and workflow are not required offline only, so the check is done + // 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 d37c64807..7f31d908c 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -3322,20 +3322,13 @@ turn the questions off on a terminal too. The organization, project, workflow and require-trace values are saved to .chainloop.yml, and every push reads them from there. -The trace modes select where a session's evidence goes. --online (the default) -attests it to the Chainloop control plane. --offline stores it on disk, under -.git/chainloop-trace/local-sessions or the --trace-path directory, and needs no -login and no control plane. Pass both to do both. With no mode flag, init keeps -the modes in .chainloop.yml, or uses --online on a first run. The modes are -saved to .chainloop.yml as traceModes, so every clone records the same way. - -With --offline alone, init installs the same hooks but creates no workflow, -needs no login, and reaches no control plane. --contract, --workflow, --org and ---project are control-plane settings and need --online; --require-trace does -not. - -Run init once per repository. In a clone of an initialized repository, the -first traced session installs the git hooks. +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] @@ -3348,12 +3341,11 @@ Options --contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for init ---offline store sessions on disk, with no control plane, login or workflow; combine with --online to do both ---online attest sessions to the Chainloop control plane (the default when no mode flag is set and .chainloop.yml has no traceModes) +--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 the offline mode stores sessions (default: .git/chainloop-trace/local-sessions) +--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") ``` @@ -3387,12 +3379,11 @@ Sessions without git commits are still attested. 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 -traceModes, though: in an offline repository (traceModes: [offline]) the +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. With both modes, -the session is written to disk and attested. Either way it +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 offline session evidence, so a session never leaks setup into +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 @@ -3411,9 +3402,9 @@ Options --cursor install Cursor hooks -h, --help help for run --opencode install opencode hooks ---project string chainloop project name (required unless the repository is offline only; .chainloop.yml identity 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 unless the repository is offline only; .chainloop.yml identity 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 0cfac4900..9382d7061 100644 --- a/app/cli/internal/repositoryconfig/config.go +++ b/app/cli/internal/repositoryconfig/config.go @@ -41,13 +41,12 @@ type ChainloopYML struct { Organization string `yaml:"organization,omitempty"` WorkflowName string `yaml:"workflowName,omitempty"` - // TraceModes selects where Chainloop Trace sends a session's evidence: - // "online" attests it to the control plane, "offline" writes the evidence - // set to disk with no control plane. Both can be set at once. Absent means - // online only. It is a committed, shared repository policy, so every clone - // of the repository records the same way. - TraceModes []string `yaml:"traceModes,omitempty"` - // TracePath names where sessions are stored in offline mode. Empty falls + // 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 diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 6dfeba722..79d5090aa 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, in which trace modes, and where the evidence goes: the Chainloop project in the online mode, and the on-disk path in the offline mode (spec issue-3582). | 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,24 +99,17 @@ 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 | In the online trace mode (the default), the `pre-push` hook attests the sessions of the pushed commits to the control plane. | Yes | Yes | Yes | Yes | -| Offline storage at push | In the offline trace mode (`traceModes: [offline]` 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`), 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. With `traceModes: [online, offline]` the hook does both: it stores the set on disk first, then attests it. `trace init --offline` 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 | +| 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 | -### Trace modes +### Switching storage modes -`traceModes` in `.chainloop.yml` selects where a session's evidence goes. `online` attests it to the control plane. `offline` stores it on disk. A repository can use one mode or both. With no `traceModes`, the mode is `online`. +`traceStorage` in `.chainloop.yml` is a committed repository policy, so it is the same for every clone: once a repository is `LOCAL`, every session every user records is stored on disk. To change the mode: -`traceModes` is a committed repository policy, so it is the same for every clone. To change it, run `chainloop trace init` with the modes you want, then commit `.chainloop.yml`: +- **To local:** run `chainloop trace init --local`. It writes `traceStorage: LOCAL` (and `tracePath` with `--trace-path`), installs the hooks, and contacts no control plane. Commit `.chainloop.yml` to share the choice. +- **Back to the control plane:** run `chainloop trace init` (without `--local`). It writes `traceStorage: CONTROL_PLANE`, clearing the local policy, and sets up the workflow as usual. The command tells you it switched. Commit `.chainloop.yml`. -- **Offline only:** `chainloop trace init --offline`. It installs the hooks and contacts no control plane. -- **Online only:** `chainloop trace init --online`. It sets up the workflow as usual. -- **Both:** `chainloop trace init --online --offline`. - -`chainloop trace init` with no mode flag keeps the modes in `.chainloop.yml`. The command tells you when the modes change. - -Run `chainloop trace init` once per repository. The git hooks live under `.git` and are not cloned, but in a clone the first traced session installs them, so a teammate does not run init again. - -When the online mode fails at push time, for example because the control plane is not available, the offline copy is still stored. The session stays in the trace state, and the next push tries the online mode again. +The git hooks live under `.git` and are not cloned, so each teammate runs `chainloop trace init` (or `chainloop trace init --local`) once after cloning to install their own. ## 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 117db90b4..05c9bd281 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -86,87 +86,62 @@ func SaveRequireTraceToYML(dir string, val bool) error { } const ( - // TraceModeOnline attests each session to the Chainloop control plane. It - // is the mode when traceModes is unset. - TraceModeOnline = "online" - // TraceModeOffline writes each session's evidence set to disk, with no + // 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. - TraceModeOffline = "offline" + TraceStorageLocal = "LOCAL" ) -// TraceModes is where Chainloop Trace sends a session's evidence. The modes are -// independent, so a repository can attest to the control plane and keep a copy -// on disk at the same time. -type TraceModes struct { - Online bool - Offline bool -} - -// Names lists the enabled modes in a fixed order, online first, as they are -// written to .chainloop.yml and shown to the user. -func (m TraceModes) Names() []string { - names := make([]string, 0, 2) - if m.Online { - names = append(names, TraceModeOnline) +// 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) } - if m.Offline { - names = append(names, TraceModeOffline) - } - - return names } -// String renders the enabled modes for the user, for example "online, offline". -func (m TraceModes) String() string { - return strings.Join(m.Names(), ", ") -} - -// ParseTraceModes canonicalizes a traceModes list: each entry is trimmed and -// lower-cased, and a repeated entry counts once. An absent or empty list is -// online only. Any entry other than the two known modes is an error, so a -// committed typo surfaces instead of silently falling back to online (and -// pushing to a backend an offline repository means to avoid). -func ParseTraceModes(values []string) (TraceModes, error) { - if len(values) == 0 { - return TraceModes{Online: true}, nil - } - - var modes TraceModes - for _, v := range values { - switch strings.ToLower(strings.TrimSpace(v)) { - case TraceModeOnline: - modes.Online = true - case TraceModeOffline: - modes.Offline = true - default: - return TraceModes{}, fmt.Errorf("unknown trace mode %q in traceModes of .chainloop.yml: want %s or %s", v, TraceModeOnline, TraceModeOffline) - } - } - - return modes, nil -} - -// LoadTraceModesFromYML reads and canonicalizes the traceModes list from the -// nearest .chainloop.yml. A missing config or field is online only, but a +// 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 -// online. An offline repository whose config broke must not have its pre-push -// hook fall back to the control plane and upload an offline-only session. -func LoadTraceModesFromYML(dir string) (TraceModes, error) { +// 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 TraceModes{Online: true}, nil + return TraceStorageControlPlane, nil } if err != nil { - return TraceModes{}, err + return "", err } - return ParseTraceModes(cfg.TraceModes) + 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 } -// SaveTraceModesToYML writes (or updates) the traceModes field in +// SaveTraceStorageToYML writes (or updates) the traceStorage field in // .chainloop.yml at the given directory, preserving any other fields. -func SaveTraceModesToYML(dir string, modes TraceModes) error { - return updateChainloopYMLField(dir, "traceModes", modes.Names()) +func SaveTraceStorageToYML(dir, mode string) error { + return updateChainloopYMLField(dir, "traceStorage", mode) } // SaveTracePathToYML writes (or updates) the tracePath field in .chainloop.yml diff --git a/app/cli/internal/trace/config/modes_test.go b/app/cli/internal/trace/config/modes_test.go deleted file mode 100644 index fe6b959cf..000000000 --- a/app/cli/internal/trace/config/modes_test.go +++ /dev/null @@ -1,165 +0,0 @@ -// -// 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 TestParseTraceModes(t *testing.T) { - testCases := []struct { - name string - values []string - want TraceModes - wantErr bool - }{ - {name: "absent defaults to online", values: nil, want: TraceModes{Online: true}}, - {name: "empty list defaults to online", values: []string{}, want: TraceModes{Online: true}}, - {name: "online", values: []string{"online"}, want: TraceModes{Online: true}}, - {name: "offline", values: []string{"offline"}, want: TraceModes{Offline: true}}, - {name: "both", values: []string{"online", "offline"}, want: TraceModes{Online: true, Offline: true}}, - {name: "order does not matter", values: []string{"offline", "online"}, want: TraceModes{Online: true, Offline: true}}, - {name: "case and space are canonicalized", values: []string{" OFFLINE ", "Online"}, want: TraceModes{Online: true, Offline: true}}, - {name: "a repeated mode is one mode", values: []string{"offline", "offline"}, want: TraceModes{Offline: true}}, - {name: "an unknown mode is an error", values: []string{"local"}, wantErr: true}, - {name: "an empty entry is an error", values: []string{"online", ""}, wantErr: true}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - got, err := ParseTraceModes(tc.values) - if tc.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } -} - -func TestTraceModesNames(t *testing.T) { - assert.Equal(t, []string{"online"}, TraceModes{Online: true}.Names()) - assert.Equal(t, []string{"offline"}, TraceModes{Offline: true}.Names()) - assert.Equal(t, []string{"online", "offline"}, TraceModes{Online: true, Offline: true}.Names()) - assert.Empty(t, TraceModes{}.Names()) -} - -func TestLoadTraceModesFromYML(t *testing.T) { - testCases := []struct { - name string - yml string - want TraceModes - wantErr bool - }{ - {name: "no field is online", yml: "projectName: p\n", want: TraceModes{Online: true}}, - {name: "offline only", yml: "traceModes:\n - offline\n", want: TraceModes{Offline: true}}, - {name: "both in flow style", yml: "traceModes: [online, offline]\n", want: TraceModes{Online: true, Offline: true}}, - {name: "an unknown mode is an error", yml: "traceModes: [lcoal]\n", wantErr: true}, - // An offline repository whose config broke must not fall back to - // online and upload an offline-only session: the pre-push hook reads - // this, and a silent online default would do exactly that. - {name: "a malformed config is an error, not a silent online", yml: "traceModes: {not: a list}\n", wantErr: true}, - } - - 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)) - - got, err := LoadTraceModesFromYML(dir) - if tc.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } - - t.Run("no config file is online", func(t *testing.T) { - got, err := LoadTraceModesFromYML(t.TempDir()) - require.NoError(t, err) - assert.Equal(t, TraceModes{Online: true}, got) - }) -} - -func TestTraceModesRoundTrip(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, SaveTraceModesToYML(dir, TraceModes{Online: true, Offline: true})) - require.NoError(t, SaveTracePathToYML(dir, "evidence/sessions")) - - got, err := LoadTraceModesFromYML(dir) - require.NoError(t, err) - assert.Equal(t, TraceModes{Online: true, Offline: true}, got) - 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/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 9eca9549c..93ba9c17e 100644 --- a/app/cli/internal/trace/state/state.go +++ b/app/cli/internal/trace/state/state.go @@ -44,7 +44,7 @@ const ( traceDirCommits = "commits" traceDirSnapshots = "snapshots" traceDirAILines = "ai-lines" - // traceDirLocalSessions holds the evidence sets written by the offline trace + // 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. @@ -105,7 +105,7 @@ func (s *Store) SpecRedactionDir(sessionID string) string { return filepath.Join(s.traceDirPath(), traceDirSpecRedactions, SanitizeID(sessionID)) } -// LocalSessionsDir returns the default directory the offline trace mode writes its +// 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. @@ -349,7 +349,7 @@ func (s *Store) staleSessionIDs(unattested map[string]struct{}) map[string]struc // RemoveTraceDir removes the trace state but preserves local-sessions/ and any // preservePaths that resolve inside the trace directory, the durable evidence -// sets an offline-mode push or `trace run` wrote. A developer keeps those as they +// 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 diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index 993104152..e22c9f2bb 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -110,7 +110,7 @@ func TestWipeTraceDir(t *testing.T) { } // TestRemoveTraceDirPreservesLocalSessions pins that RemoveTraceDir keeps the -// local-sessions directory — the durable evidence an offline-mode push or +// 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) { @@ -118,7 +118,7 @@ func TestRemoveTraceDirPreservesLocalSessions(t *testing.T) { 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 the offline mode would write it. + // 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)) diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 0ce11b2c6..25cd6ac18 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -112,61 +112,16 @@ func setSessionActive(store *state.Store, sessionID string, active bool, log zer } } -// bannerDestination is what the session-start banner knows about where the -// evidence goes. The online fields name the control plane, the offline fields -// the directory on disk. Each one is left empty when it is unknown. -type bannerDestination struct { - dashboardURL, org, project string - tracePath string - // traceRunActive tells that `trace run` drives the session, so the evidence - // is stored when the run finishes, not on a push. - traceRunActive bool -} - // sessionStartBanner is what the developer sees at the top of a traced // session. Its audience includes someone who cloned the repository and never -// ran init, so it says in plain words which trace modes record the session -// and, when we know it, where the evidence goes. "Attested" is deliberately -// absent: it does not tell a newcomer whether something is recorded, uploaded, -// or signed, let alone to where. +// ran init, so it says what is happening in plain words and, when we know it, +// where the evidence goes. Chainloop's own vocabulary is deliberately absent: +// "attested" does not tell a newcomer whether something is recorded, +// uploaded, or signed, let alone to where. // // Each fact is dropped rather than guessed at when it is unknown, so the // banner never promises a destination that was not confirmed. -func sessionStartBanner(modes config.TraceModes, dest bannerDestination) string { - var banner string - switch { - case modes.Online && modes.Offline: - banner = "Chainloop Trace is recording this session in online and offline modes." - case modes.Offline: - banner = "Chainloop Trace is recording this session in offline mode." - default: - banner = "Chainloop Trace is recording this session in online mode." - } - - if modes.Online { - if where := onlineDestination(dest.dashboardURL, dest.org, dest.project); where != "" { - banner += "\n" + where - } - } - - // R-012: the offline mode says where the evidence lands on disk. - if modes.Offline && dest.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 dest.traceRunActive { - when = "when this run finishes" - } - banner += "\nEvidence will be stored on disk at " + dest.tracePath + " " + when + "." - } - - return banner -} - -// onlineDestination names where the online mode sends the evidence: the -// dashboard, qualified by the organization and project when they are known. -func onlineDestination(dashboardURL, org, project string) string { +func sessionStartBanner(dashboardURL, org, project string) string { var identity []string if org != "" { identity = append(identity, "organization: "+org) @@ -187,7 +142,32 @@ func onlineDestination(dashboardURL, org, project string) string { where = "Evidence will be sent to " + strings.TrimRight(dashboardURL, "/") } - return where + banner := "Chainloop Trace is recording this session." + if where != "" { + banner += "\n" + where + } + + 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. @@ -226,27 +206,21 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error } if provider.SupportsSessionStartBanner() { - // An unreadable traceModes falls back to the online banner: the banner - // only informs, and the pre-push hook reports the broken value. - modes, err := config.LoadTraceModesFromYML(repoRoot) - if err != nil { - log.Debug().Err(err).Msg("session-start: could not read traceModes") - modes = config.TraceModes{Online: true} - } - - dest := bannerDestination{traceRunActive: store.IsTraceRunActive()} - // Only the online mode makes the dashboard round trip; the offline mode - // reaches no control plane. - if modes.Online { - dest.dashboardURL = hookDashboardURL(log) - dest.org = config.LoadOrganizationFromYML(repoRoot) - dest.project = repositoryconfig.LoadProjectFromYML(repoRoot) - } - if modes.Offline { - dest.tracePath = ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(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), + ) } - - banner := sessionStartBanner(modes, dest) msg.Banner = "\n\n" + banner + "\n" } @@ -800,13 +774,6 @@ func recordRootChanges(store *state.Store, repoRoot string, before map[string]st // .git/chainloop-trace/, which is robust to whatever environment each // agent happens to propagate to its hook subprocesses. // Only meaningful for a store inside a repository; GitDir is empty otherwise. -// offlineModeEnabled reports whether the repository's traceModes include the -// offline mode. An unreadable value counts as not enabled. -func offlineModeEnabled(repoRoot string) bool { - modes, err := config.LoadTraceModesFromYML(repoRoot) - return err == nil && modes.Offline -} - func autoInstallGitHooks(store *state.Store, repoRoot string, log zerolog.Logger) { gitDir := store.GitDir() skipPrePush := store.IsTraceRunActive() @@ -816,10 +783,10 @@ func autoInstallGitHooks(store *state.Store, repoRoot string, log zerolog.Logger } // Auto-install needs a repository that is set up for trace: a control-plane - // project name, or the offline mode. An offline-only repository writes no - // projectName, so without the offline check its hooks would never be - // installed in a fresh clone, and every teammate would have to run init. - if repositoryconfig.LoadProjectFromYML(repoRoot) == "" && !offlineModeEnabled(repoRoot) { + // 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 } diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index 769d8a2f9..ee3b95651 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -127,7 +127,7 @@ func TestHandleAgentSessionStart(t *testing.T) { }) assert.Contains(t, stdout, `"systemMessage"`) - assert.Contains(t, stdout, "Chainloop Trace is recording this session in online mode.") + assert.Contains(t, stdout, "Chainloop Trace is recording this session.") }) t.Run("carries the banner and the spec instruction in one document", func(t *testing.T) { @@ -152,7 +152,7 @@ func TestHandleAgentSessionStart(t *testing.T) { require.NoError(t, dec.Decode(&got)) require.ErrorIs(t, dec.Decode(&map[string]any{}), io.EOF, "stdout must carry exactly one JSON document") - assert.Contains(t, got["systemMessage"], "Chainloop Trace is recording this session in online mode.") + assert.Contains(t, got["systemMessage"], "Chainloop Trace is recording this session.") hookOut, ok := got["hookSpecificOutput"].(map[string]any) require.True(t, ok) @@ -184,7 +184,7 @@ func TestHandleAgentSessionStart(t *testing.T) { hookOut, ok := got["hookSpecificOutput"].(map[string]any) require.True(t, ok) assert.NotContains(t, hookOut, "additionalContext", "the instruction is not worth repeating") - assert.Contains(t, got["systemMessage"], "Chainloop Trace is recording this session in online mode.", "the banner still goes out") + assert.Contains(t, got["systemMessage"], "Chainloop Trace is recording this session.", "the banner still goes out") }) t.Run("ignores malformed stdin", func(t *testing.T) { diff --git a/app/cli/pkg/action/trace_banner_test.go b/app/cli/pkg/action/trace_banner_test.go index 3396e3b1e..2797a0a8c 100644 --- a/app/cli/pkg/action/trace_banner_test.go +++ b/app/cli/pkg/action/trace_banner_test.go @@ -22,7 +22,6 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" @@ -57,7 +56,7 @@ func (p *bannerProvider) AnnounceSessionStart(msg trace.SessionStartMessage) err // evidence goes without assuming any Chainloop vocabulary. func TestSessionStartBanner(t *testing.T) { const ( - recording = "Chainloop Trace is recording this session in online mode." + recording = "Chainloop Trace is recording this session." testProject = "chainloop-cli" ) @@ -122,12 +121,7 @@ func TestSessionStartBanner(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - got := sessionStartBanner(config.TraceModes{Online: true}, bannerDestination{ - dashboardURL: tc.dashboardURL, - org: tc.org, - project: tc.project, - }) - assert.Equal(t, tc.want, got) + assert.Equal(t, tc.want, sessionStartBanner(tc.dashboardURL, tc.org, tc.project)) }) } } @@ -187,7 +181,7 @@ func TestSessionStartChannelGate(t *testing.T) { assert.Equal(t, tc.wantSent, p.sysCalls) if tc.wantBanner { - assert.Contains(t, p.last.Banner, "Chainloop Trace is recording this session in online mode.") + assert.Contains(t, p.last.Banner, "Chainloop Trace is recording this session.") } else { assert.Empty(t, p.last.Banner) } diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index ccd2a71b2..ce1292149 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -19,7 +19,6 @@ import ( "bufio" "context" "encoding/json" - "errors" "fmt" "io" "maps" @@ -529,16 +528,44 @@ type RunTracePushOpts struct { CLIVersion string } -// HandlePrePushHook handles the pre-push git hook: it sends the recorded -// sessions to every enabled trace mode. The caller passes a returned error -// through PrePushFailure. -func HandlePrePushHook(ctx context.Context, log zerolog.Logger, opts RunTraceStoreOpts) error { +// HandlePrePushHook handles the pre-push git hook. The caller passes a +// returned error through PrePushFailure. +func HandlePrePushHook(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts) error { drainPushStdin() - opts.NoRedact = traceNoRedactFromEnv() - _, err := RunTraceStore(ctx, log, opts) + return RunTracePush(ctx, log, opts) +} - return err +// 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 @@ -561,142 +588,31 @@ func PrePushFailure(err error, requireTrace bool, log zerolog.Logger) error { } // RunTracePush gathers AI-coding-session evidence from the local trace -// state and emits a Chainloop attestation. It is RunTraceStore with the online -// mode only. -func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts) error { - _, err := RunTraceStore(ctx, log, RunTraceStoreOpts{ - Modes: config.TraceModes{Online: true}, - Push: opts, - }) - - return err -} - -// RunTraceStoreOpts configures one run of the trace modes over the recorded -// sessions. -type RunTraceStoreOpts struct { - // Modes selects where the evidence goes. Online attests it to the control - // plane, offline writes it to disk. Both can be set. - Modes config.TraceModes - // Push configures the run. Its Mode and AllowEmpty apply to both modes; the - // rest of it is the online identity and connection, unused offline. - Push RunTracePushOpts - // NoRedact disables secret redaction in the offline store, for a trusted - // local store only (R-004). The online attestation always redacts. - NoRedact bool -} - -// RunTraceStoreResult tells the caller what the offline mode stored. -type RunTraceStoreResult struct { - // StoredLocally is the number of sessions written to disk. - StoredLocally int - // TracePath is the directory the offline mode writes under. It is set - // whenever the offline mode is enabled, also when nothing was stored. - TracePath string -} - -// RunTraceStore sends the recorded sessions to every enabled trace mode. Each -// mode assembles its own copy of the evidence from the same recorded state, -// because the assembly rewrites the evidence it attaches. The offline mode runs -// first: it needs no network, so a control-plane failure cannot cost the local -// copy. +// state and emits a Chainloop attestation. Shared between the pre-push +// git hook and `chainloop trace run`. // -// The single-use trace state is pruned only when every enabled mode finished -// its work. A mode that failed leaves the sessions in place, so the next push -// retries it. Storing a session offline again is harmless: the store is -// content-addressed and rewrites the same bytes. -func RunTraceStore(ctx context.Context, log zerolog.Logger, opts RunTraceStoreOpts) (RunTraceStoreResult, error) { - var res RunTraceStoreResult - +//nolint:gocyclo // straight-line orchestration of the pre-push flow; splitting it would only move the branches +func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts) error { store, repoRoot, err := state.Locate() if err != nil { - return res, err + return err } - sessionMode := aicodingsession.ResolveMode(opts.Push.Mode) + sessionMode := aicodingsession.ResolveMode(opts.Mode) log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot). - Bool("allow_empty", opts.Push.AllowEmpty).Str("mode", sessionMode). - Str("trace_modes", opts.Modes.String()).Msg("trace store invoked") - - var ( - errs []error - aiCommits []*state.CommitRecord - // done is set when a mode recorded something, and complete is cleared - // when an enabled mode did not finish. Both gate the final prune. - done bool - complete = true - ) - - if opts.Modes.Offline { - res.TracePath = ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(repoRoot)) - - sessions, commits, err := gatherPushSessions(ctx, store, repoRoot, sessionMode, opts.Push.AllowEmpty, log) - aiCommits = commits - - switch { - case err != nil: - errs = append(errs, err) - complete = false - case len(sessions) == 0: - log.Info().Msg("no new AI coding session evidence, nothing stored on disk") - default: - stored, err := storeSessionsLocally(ctx, log, store, sessions, res.TracePath, opts.NoRedact) - res.StoredLocally = stored - - if stored > 0 { - log.Info().Int("sessions", stored).Msgf("AI coding session evidence stored at %s", res.TracePath) - } + Bool("allow_empty", opts.AllowEmpty).Str("mode", sessionMode).Msg("trace push invoked") - switch { - case err != nil: - errs = append(errs, fmt.Errorf("store session evidence locally: %w", err)) - complete = false - case stored != len(sessions): - complete = false - default: - done = true - } - } - } - - if opts.Modes.Online { - sessions, commits, err := gatherPushSessions(ctx, store, repoRoot, sessionMode, opts.Push.AllowEmpty, log) - aiCommits = commits - - switch { - case err != nil: - errs = append(errs, err) - complete = false - case len(sessions) == 0: - log.Info().Msg("no new AI coding session evidence, skipping attestation") - default: - pushed, err := pushSessions(ctx, log, store, repoRoot, sessions, opts.Push) - switch { - case err != nil: - errs = append(errs, err) - complete = false - case !pushed: - complete = false - default: - done = true - } - } + sessions, aiCommits, err := gatherPushSessions(ctx, store, repoRoot, sessionMode, opts.AllowEmpty, log) + if err != nil { + return err } + if len(sessions) == 0 { + log.Info().Msg("no new AI coding session evidence, skipping attestation") - // Pruning while one mode failed would mark its sessions tracked and wipe - // their state, with no way back for that mode. - if done && complete { - finalizePush(store, repoRoot, aiCommits, log) + return nil } - return res, errors.Join(errs...) -} - -// pushSessions attests the assembled sessions to the control plane in one -// attestation. It reports whether an attestation went out: false with no error -// means no session evidence could be added, so nothing was sent. -func pushSessions(ctx context.Context, log zerolog.Logger, store *state.Store, repoRoot string, sessions []sessionEvidence, opts RunTracePushOpts) (bool, error) { // Create attestation using local state file to avoid conflicts with other attestations localStatePath := store.AttestationStatePath() defer func() { _ = os.Remove(localStatePath) }() @@ -704,10 +620,10 @@ func pushSessions(ctx context.Context, log zerolog.Logger, store *state.Store, r projectName, organization, workflowName := resolvePushIdentity(repoRoot, opts) if projectName == "" { if opts.IgnoreYAML { - return false, fmt.Errorf("no project name provided") + return fmt.Errorf("no project name provided") } - return false, fmt.Errorf("no project name found (pass via options or .chainloop.yml)") + return fmt.Errorf("no project name found (pass via options or .chainloop.yml)") } log.Debug().Str("project", projectName).Msg("resolved project for trace push") @@ -724,13 +640,13 @@ func pushSessions(ctx context.Context, log zerolog.Logger, store *state.Store, r // unless require-trace is on. log.Warn().Err(err).Msg("skipping trace attestation") - return false, err + return err } defer func() { _ = executor.Close() }() attestationID, err := executor.Init(ctx, workflowName, projectName, opts.ProjectVersion) if err != nil { - return false, fmt.Errorf("attestation init: %w", err) + return fmt.Errorf("attestation init: %w", err) } log.Debug().Str("attestation_id", attestationID).Msg("attestation initialized") @@ -743,14 +659,14 @@ func pushSessions(ctx context.Context, log zerolog.Logger, store *state.Store, r log.Debug().Msg("no evidence successfully added, resetting attestation") _ = executor.Reset(ctx, AttestationResetTriggerCancelled, "no CHAINLOOP_AI_CODING_SESSION evidence added") - return false, nil + return nil } // Push attestation log.Debug().Msg("pushing attestation") res, err := executor.Push(ctx) if err != nil { - return false, fmt.Errorf("attestation push: %w", err) + return fmt.Errorf("attestation push: %w", err) } // Tell the user where each session landed. The organization comes from the @@ -774,13 +690,17 @@ func pushSessions(ctx context.Context, log zerolog.Logger, store *state.Store, r log.Debug().Err(err).Msg("could not record session links for the agent hook") } + log.Debug().Msg("attestation pushed, wiping single-use trace state") + // A later push of each session keeps the spec files recorded here, so // that the limit never drops a spec that is already in the evidence. for sessionID, fileNames := range attestedSpecs { recordPushedSpecs(store, sessionID, fileNames, log) } - return true, nil + finalizePush(store, repoRoot, aiCommits, log) + + return nil } // sessionCommitGroups groups the AI commits by the sessions that contributed to diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index 584b9af20..c471f94d2 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -25,9 +25,11 @@ import ( "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" ) @@ -54,7 +56,74 @@ const ( materialsDirName = "materials" ) -// ResolveTracePath returns where the offline trace mode writes its evidence sets: +// 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 { diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index d85f2af6f..975780767 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -26,7 +26,6 @@ import ( "testing" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "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" @@ -142,23 +141,20 @@ func TestStoreSessionsLocally(t *testing.T) { }) } -// TestRunTraceStoreOfflineNoSessions covers the offline 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 TestRunTraceStoreOfflineNoSessions(t *testing.T) { +// 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()) - res, err := RunTraceStore(t.Context(), zerolog.Nop(), RunTraceStoreOpts{ - Modes: config.TraceModes{Offline: true}, - Push: RunTracePushOpts{Mode: aicodingsession.ModeCoding}, - }) + stored, tracePath, err := RunTraceLocalStore(t.Context(), zerolog.Nop(), RunTraceLocalStoreOpts{Mode: aicodingsession.ModeCoding}) require.NoError(t, err) - assert.Zero(t, res.StoredLocally, "nothing recorded, nothing stored") - assert.Equal(t, state.NewGitStore(gitDir).LocalSessionsDir(), res.TracePath, "the default path is resolved and returned") + 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(res.TracePath) + _, statErr := os.Stat(tracePath) assert.True(t, os.IsNotExist(statErr), "no output directory is created when there is nothing to store") } @@ -173,58 +169,32 @@ func TestResolveTracePath(t *testing.T) { assert.Equal(t, filepath.Join(repoRoot, "evidence"), ResolveTracePath(repoRoot, store, "evidence")) } -// TestOfflineSessionStartBanner covers R-012 and the mode names: the banner -// says which trace modes record the session and where the evidence goes. -func TestOfflineSessionStartBanner(t *testing.T) { - const sessionsDir = "/repo/.git/chainloop-trace/local-sessions" +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") + }) - testCases := []struct { - name string - modes config.TraceModes - dest bannerDestination - want string - }{ - { - name: "offline names the on-disk destination and the push timing", - modes: config.TraceModes{Offline: true}, - dest: bannerDestination{tracePath: sessionsDir}, - want: "Chainloop Trace is recording this session in offline mode." + - "\nEvidence will be stored on disk at " + sessionsDir + " when you push.", - }, - { - name: "offline says this run finishes under trace run", - modes: config.TraceModes{Offline: true}, - dest: bannerDestination{tracePath: sessionsDir, traceRunActive: true}, - want: "Chainloop Trace is recording this session in offline mode." + - "\nEvidence will be stored on disk at " + sessionsDir + " when this run finishes.", - }, - { - name: "offline drops the destination when unknown", - modes: config.TraceModes{Offline: true}, - want: "Chainloop Trace is recording this session in offline mode.", - }, - { - name: "offline ignores the control-plane identity", - modes: config.TraceModes{Offline: true}, - dest: bannerDestination{dashboardURL: testDashboardURL, org: testOrgName, tracePath: sessionsDir}, - want: "Chainloop Trace is recording this session in offline mode." + - "\nEvidence will be stored on disk at " + sessionsDir + " when you push.", - }, - { - name: "both modes name both destinations", - modes: config.TraceModes{Online: true, Offline: true}, - dest: bannerDestination{dashboardURL: testDashboardURL, org: testOrgName, tracePath: sessionsDir}, - want: "Chainloop Trace is recording this session in online and offline modes." + - "\nEvidence will be sent to " + testDashboardURL + " (organization: " + testOrgName + ")" + - "\nEvidence will be stored on disk at " + sessionsDir + " when you push.", - }, - } + 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") + }) - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, sessionStartBanner(tc.modes, tc.dest)) - }) - } + 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) { @@ -244,68 +214,3 @@ func materialByName(t *testing.T, m localManifest, name string) storedMaterial { require.Failf(t, "material not found", "material %q not found in manifest", name) return storedMaterial{} } - -// TestRunTraceStoreBothModes follows a push with the online and offline modes -// together. The offline mode stores the session on disk first. When the online -// mode then fails, the commit stays untracked, so the next push retries the -// online mode instead of losing the session. When every mode succeeds, the -// commit is marked tracked. -func TestRunTraceStoreBothModes(t *testing.T) { - const sessionID = "5d2b9e7a-1c3f-4a6b-8d9e-0f1a2b3c4d5e" - - home := t.TempDir() - t.Setenv("HOME", home) - - repoDir, gitDir := initGitRepo(t) - store := state.NewGitStore(gitDir) - require.NoError(t, store.InitTraceDir()) - t.Chdir(repoDir) - - projectDir := claudeProjectDir(home, repoDir) - require.NoError(t, os.MkdirAll(projectDir, 0o700)) - tr := transcript{t: t, path: filepath.Join(projectDir, sessionID+".jsonl")} - tr.append(`{"type":"user","uuid":"u1","timestamp":"2026-10-15T10:00:00.000Z","sessionId":"` + sessionID + `","message":{"role":"user","content":"Add a hello world."}}`) - - payload, err := json.Marshal(struct { - SessionID string `json:"session_id"` - HookEventName string `json:"hook_event_name"` - TranscriptPath string `json:"transcript_path"` - Cwd string `json:"cwd"` - }{sessionID, "SessionStart", tr.path, repoDir}) - require.NoError(t, err) - withStdin(t, string(payload)) - captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(claude.New(), zerolog.Nop())) }) - - sha := headSHA(t, repoDir) - require.NoError(t, store.SaveCommitRecord(&state.CommitRecord{ - SHA: sha, - Message: "initial", - SessionIDs: []string{sessionID}, - Timestamp: state.NowTimestamp(), - })) - - tracePath := filepath.Join(repoDir, "sessions") - require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("tracePath: sessions\n"), 0o600)) - - // The online mode fails before it reaches any control plane: no project - // name is known. - res, err := RunTraceStore(t.Context(), zerolog.Nop(), RunTraceStoreOpts{ - Modes: config.TraceModes{Online: true, Offline: true}, - Push: RunTracePushOpts{IgnoreYAML: true, Mode: aicodingsession.ModeCoding}, - }) - require.ErrorContains(t, err, "no project name") - assert.Equal(t, 1, res.StoredLocally, "the offline mode stored the session before the online mode failed") - assert.Equal(t, tracePath, res.TracePath) - assert.FileExists(t, filepath.Join(tracePath, state.SanitizeID(sessionID), manifestFileName)) - assert.False(t, mustLoadRecord(t, store, sha).Tracked, "a failed mode keeps the commit for the next push") - - // The offline mode alone now finishes every enabled mode, so the commit is - // marked tracked. - res, err = RunTraceStore(t.Context(), zerolog.Nop(), RunTraceStoreOpts{ - Modes: config.TraceModes{Offline: true}, - Push: RunTracePushOpts{Mode: aicodingsession.ModeCoding}, - }) - require.NoError(t, err) - assert.Equal(t, 1, res.StoredLocally) - assert.True(t, mustLoadRecord(t, store, sha).Tracked, "every mode succeeded, so the commit is tracked") -} diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index d9b3b9023..5d9c71eda 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -77,18 +77,17 @@ type TraceRunOpts struct { ContractRequired bool // ActionOpts is the root command's initialized options, used to build - // the attestation executor. Required when Modes.Online is set; the - // offline mode reaches no control plane. + // 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 - // Modes selects where the session goes. Online attests it to the control - // plane after a clean run. Offline writes it to disk at session end, also - // after a failed run, and contacts no control plane. With offline only, the - // identity fields above are unused. - Modes config.TraceModes + // 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 @@ -106,9 +105,10 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error return fmt.Errorf("no trace providers selected") } - // Only the online mode reaches the control plane, so offline alone skips - // the credential check and the up-front workflow creation. - if opts.Modes.Online { + // 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)) @@ -193,52 +193,60 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error runErr := sub.Run() - // The offline 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 online mode, in contrast, - // attests only a clean run. Either way the agent's own exit code is - // preserved. - modes := opts.Modes - if runErr != nil { - modes.Online = false - } - - if !modes.Online && !modes.Offline { - return subprocessError(opts.Command[0], runErr) - } - - log.Debug().Str("trace_modes", modes.String()).Msg("wrapped command finished; recording session") - - _, storeErr := RunTraceStore(ctx, log, RunTraceStoreOpts{ - Modes: modes, - NoRedact: traceNoRedactFromEnv(), - Push: RunTracePushOpts{ - AllowEmpty: true, - ProjectName: opts.ProjectName, - Organization: opts.Organization, - WorkflowName: opts.WorkflowName, - ProjectVersion: opts.ProjectVersion, - IgnoreYAML: true, - SkipAgentNotification: true, - Mode: aicodingsession.ModeGeneric, - ActionOpts: opts.ActionOpts, - CLIVersion: opts.CLIVersion, - }, - }) + // 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 record the evidence is never reported as success. - if runErr != nil { + // 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 { - log.Warn().Err(storeErr).Msg("could not store session evidence") + return fmt.Errorf("store session evidence locally: %w", storeErr) } + return nil + } + + if runErr != nil { return subprocessError(opts.Command[0], runErr) } - return storeErr + log.Debug().Msg("wrapped command completed; attesting session") + + return RunTracePush(ctx, log, RunTracePushOpts{ + AllowEmpty: true, + ProjectName: opts.ProjectName, + Organization: opts.Organization, + WorkflowName: opts.WorkflowName, + ProjectVersion: opts.ProjectVersion, + IgnoreYAML: true, + SkipAgentNotification: true, + Mode: aicodingsession.ModeGeneric, + ActionOpts: opts.ActionOpts, + CLIVersion: opts.CLIVersion, + }) } // subprocessError maps the error from running the wrapped command into the From 87da772ed07dc0b812f7e31c30e1bd1e389d9585 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 13:26:20 +0200 Subject: [PATCH 10/19] fix(trace): do not tell teammates to run trace init --local A clone of a local-mode repository needs no trace init: the first traced session installs the git hooks, as in control-plane mode. Remove the next-steps text that told each teammate to run init after cloning, fix the README to match, and test the auto-install for a local-mode clone with no project name. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/trace_init.go | 9 ++------- app/cli/cmd/trace_init_local_test.go | 9 ++++----- app/cli/internal/trace/README.md | 2 +- app/cli/pkg/action/trace_agent_hook_test.go | 22 +++++++++++++++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index b1069c69f..23fedf228 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -382,9 +382,8 @@ func writeTraceStorageSwitchedNote(w io.Writer, ymlName string) { // 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 +// clone inherits. A teammate does not run init: in a clone, the first traced +// session installs the git hooks. 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) { @@ -413,10 +412,6 @@ What's next 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) } diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go index 06df0c809..ef76e6a5a 100644 --- a/app/cli/cmd/trace_init_local_test.go +++ b/app/cli/cmd/trace_init_local_test.go @@ -176,11 +176,10 @@ func TestWriteTraceLocalNextSteps(t *testing.T) { 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") + // In a clone, the first traced session installs the git hooks, so the + // steps must not tell a teammate to run init again. + assert.NotContains(t, got, "chainloop trace init") + assert.NotContains(t, got, "not cloned") } func TestTraceStorageToPersist(t *testing.T) { diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 79d5090aa..eb2f899df 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -109,7 +109,7 @@ How the sessions are linked to the commits and attested. - **To local:** run `chainloop trace init --local`. It writes `traceStorage: LOCAL` (and `tracePath` with `--trace-path`), installs the hooks, and contacts no control plane. Commit `.chainloop.yml` to share the choice. - **Back to the control plane:** run `chainloop trace init` (without `--local`). It writes `traceStorage: CONTROL_PLANE`, clearing the local policy, and sets up the workflow as usual. The command tells you it switched. Commit `.chainloop.yml`. -The git hooks live under `.git` and are not cloned, so each teammate runs `chainloop trace init` (or `chainloop trace init --local`) once after cloning to install their own. +Run `chainloop trace init` once per repository. The git hooks live under `.git` and are not cloned, but in a clone the first traced session installs them, so a teammate does not run init. ## How each agent is hooked in diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index ee3b95651..7ff1da667 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -412,6 +412,28 @@ func TestAutoInstallGitHooks(t *testing.T) { assert.True(t, store.IsTraceInitialized()) }) + t.Run("installs in a local-mode clone with no project name", func(t *testing.T) { + // A local-mode repository writes no projectName. A teammate who clones + // it does not run trace init, so the first session must install the + // git hooks. + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("traceStorage: LOCAL\n"), 0600)) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", "pre-push")) + require.NoError(t, err) + assert.Contains(t, string(content), hooks.HookMarker) + assert.True(t, store.IsTraceInitialized()) + }) + t.Run("skips when hooks already installed", func(t *testing.T) { repoDir := initTempGitRepo(t) gitDir := filepath.Join(repoDir, ".git") From 020af50aaf9f696880ed86d9b4f7af6590619712 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 13:30:26 +0200 Subject: [PATCH 11/19] fix(trace): write the local-mode texts in Simplified Technical English Rewrite the help, flag descriptions, init output, errors, logs and the session-start banner of the local mode with short, active sentences and no semicolons. Remove "the push tells you where" from the next steps. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/trace_init.go | 33 +++++++------- app/cli/cmd/trace_init_local_test.go | 5 ++- app/cli/cmd/trace_run.go | 25 ++++++----- app/cli/documentation/cli-reference.md | 45 +++++++++++--------- app/cli/internal/trace/README.md | 10 ++--- app/cli/internal/trace/config/config.go | 2 +- app/cli/pkg/action/trace_agent_hook.go | 2 +- app/cli/pkg/action/trace_hook_handler.go | 4 +- app/cli/pkg/action/trace_local_store.go | 2 +- app/cli/pkg/action/trace_local_store_test.go | 2 +- app/cli/pkg/action/trace_run.go | 2 +- 11 files changed, 71 insertions(+), 61 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index 23fedf228..a53d49740 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -80,13 +80,15 @@ 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.`, +With --local, init sets up the repository for local mode. It installs the same +hooks, but it creates no workflow, needs no login, and does not connect to a +control plane. It writes traceStorage: LOCAL to .chainloop.yml. It also writes +tracePath when you give --trace-path. Every clone then records sessions on +disk. On each push, the pre-push hook writes the session evidence to that path. +It does not send an attestation. + +--contract, --workflow, --org and --project are control-plane settings. You +cannot use them with --local. You can use --require-trace with --local.`, RunE: func(cmd *cobra.Command, _ []string) error { gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() if err != nil { @@ -105,7 +107,7 @@ with --local; --require-trace can.`, 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) + return fmt.Errorf("you cannot use --%s with --local: local mode does not connect to a control plane and does not create a workflow", name) } } } @@ -281,8 +283,8 @@ with --local; --require-trace can.`, 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().BoolVar(&localFlag, "local", false, "set up local mode, with no control plane, login or workflow. The pre-push hook writes the session evidence to disk") + cmd.Flags().StringVar(&tracePathFlag, "trace-path", "", "the directory where local mode writes the sessions (default: .git/chainloop-trace/local-sessions). This flag also sets --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 a required trace is missing") cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)") @@ -364,9 +366,9 @@ func writeTraceInitLocalSummary(w io.Writer, repoRoot string, harnesses []string 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) + fmt.Fprintf(w, " sessions written to %s on each push\n", path) } else { - fmt.Fprint(w, " sessions stored under .git/chainloop-trace/local-sessions on push\n") + fmt.Fprint(w, " sessions written to .git/chainloop-trace/local-sessions on each push\n") } fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) @@ -377,7 +379,7 @@ func writeTraceInitLocalSummary(w io.Writer, repoRoot string, harnesses []string // behaves, so it is stated plainly here rather than left to a debug log, and the // user is reminded to commit it like any other repository policy change. func writeTraceStorageSwitchedNote(w io.Writer, ymlName string) { - fmt.Fprintf(w, "\nThis repository was in local mode. Trace now attests sessions to the control\nplane. Commit %s so every clone switches too.\n", ymlName) + fmt.Fprintf(w, "\nThis repository was in local mode. Trace now sends the sessions to the control\nplane. Commit %s so that every clone also uses the control plane.\n", ymlName) } // writeTraceLocalNextSteps says what is left after a local init. Committing @@ -406,11 +408,10 @@ func writeTraceLocalNextSteps(w io.Writer, repoRoot, workDir string, installed [ fmt.Fprintf(w, ` What's next - 1. Commit these files so the local-mode policy is shared: + 1. Commit these files. Every clone then uses local mode: 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 + 3. Commit and push. The pre-push hook writes the session evidence to disk Learn more: %s `, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL) diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go index ef76e6a5a..db6ea3269 100644 --- a/app/cli/cmd/trace_init_local_test.go +++ b/app/cli/cmd/trace_init_local_test.go @@ -106,7 +106,7 @@ func TestTraceInitLocal(t *testing.T) { 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) + assert.ErrorContains(t, err, "cannot use", "flag %v", flag) } }) } @@ -174,7 +174,8 @@ func TestWriteTraceLocalNextSteps(t *testing.T) { got := out.String() assert.Contains(t, got, chainloopYMLName) assert.Contains(t, got, claudeSettings) - assert.Contains(t, got, "written to disk") + assert.Contains(t, got, "writes the session evidence to disk") + assert.NotContains(t, got, "the push tells you where") assert.Contains(t, got, traceDocsURL) // In a clone, the first traced session installs the git hooks, so the // steps must not tell a teammate to run init again. diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index ef349ecfb..2688967a4 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -31,15 +31,18 @@ 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 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. +trace run does not read the attestation identity from .chainloop.yml. The +--org, --project and --workflow flags (mandatory) and the optional --version +flag set it. + +trace run reads one value from .chainloop.yml: traceStorage. In a local-mode +repository (traceStorage: LOCAL), trace run writes the session to disk when the +command exits. It does not connect to a control plane, and the --org, --project +and --workflow flags are optional. + +When the wrapped command exits, trace run removes the temporary hooks and the +trace state. It keeps the stored local session evidence. Thus no setup from one +session stays for the next session. 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 @@ -165,8 +168,8 @@ func newTraceRunCmd() *cobra.Command { }, } - 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(&projectFlag, "project", "", "chainloop project name (required, except in local mode). trace run does not read it from .chainloop.yml") + cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name for the trace attestations (required, except in local mode). trace run does not read it from .chainloop.yml") 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)") diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 7f31d908c..07d25238e 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -3322,13 +3322,15 @@ 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. +With --local, init sets up the repository for local mode. It installs the same +hooks, but it creates no workflow, needs no login, and does not connect to a +control plane. It writes traceStorage: LOCAL to .chainloop.yml. It also writes +tracePath when you give --trace-path. Every clone then records sessions on +disk. On each push, the pre-push hook writes the session evidence to that path. +It does not send an attestation. + +--contract, --workflow, --org and --project are control-plane settings. You +cannot use them with --local. You can use --require-trace with --local. ``` chainloop trace init [flags] @@ -3341,11 +3343,11 @@ Options --contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for init ---local set up local mode: no control plane, no login, no workflow; the pre-push hook stores session evidence on disk +--local set up local mode, with no control plane, login or workflow. The pre-push hook writes the session evidence to 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 +--trace-path string the directory where local mode writes the sessions (default: .git/chainloop-trace/local-sessions). This flag also sets --local --workflow string chainloop workflow name used for trace attestations (defaults to "ai-coding-session") ``` @@ -3376,15 +3378,18 @@ 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 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. +trace run does not read the attestation identity from .chainloop.yml. The +--org, --project and --workflow flags (mandatory) and the optional --version +flag set it. + +trace run reads one value from .chainloop.yml: traceStorage. In a local-mode +repository (traceStorage: LOCAL), trace run writes the session to disk when the +command exits. It does not connect to a control plane, and the --org, --project +and --workflow flags are optional. + +When the wrapped command exits, trace run removes the temporary hooks and the +trace state. It keeps the stored local session evidence. Thus no setup from one +session stays for the next session. 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 @@ -3402,9 +3407,9 @@ Options --cursor install Cursor hooks -h, --help help for run --opencode install opencode hooks ---project string chainloop project name (required unless the repository is in local mode; .chainloop.yml identity is ignored) +--project string chainloop project name (required, except in local mode). trace run does not read it from .chainloop.yml --version string chainloop project version (optional; defaults to the latest version) ---workflow string chainloop workflow name used for trace attestations (required unless the repository is in local mode; .chainloop.yml identity is ignored) +--workflow string chainloop workflow name for the trace attestations (required, except in local mode). trace run does not read it from .chainloop.yml ``` Options inherited from parent commands diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index eb2f899df..650c6fa6c 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -100,16 +100,16 @@ How the sessions are linked to the commits and attested. |---------|---------------|-------------|--------|--------------|------------| | Commit trailer | Each commit gets a `Chainloop-Trace-Sessions` trailer with the sessions that contributed to it. | Yes | Yes | Yes | Yes | | Attestation at push | 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 | +| Local storage at push | In a repository with `traceStorage: LOCAL` in `.chainloop.yml`, the `pre-push` hook runs the same assembly as an attestation. It writes the session evidence set to `tracePath` (default `.git/chainloop-trace/local-sessions`) and sends no attestation. It needs no control plane, login, or network. The stored set is equal to the upload of a push, except for the signature. A `manifest.json` file marks the set as unsigned and not an attestation. `trace init --local` sets up the mode, and `trace run` uses it too. Redaction is on by default. Set `CHAINLOOP_TRACE_NO_REDACT` to turn it off, for a trusted local store only (spec issue-3582). | Yes | Yes | Yes | Yes | ### Switching storage modes -`traceStorage` in `.chainloop.yml` is a committed repository policy, so it is the same for every clone: once a repository is `LOCAL`, every session every user records is stored on disk. To change the mode: +`traceStorage` in `.chainloop.yml` is a committed repository policy. It is the same for every clone. When a repository uses `LOCAL`, Trace stores every session of every user on disk. To change the mode: -- **To local:** run `chainloop trace init --local`. It writes `traceStorage: LOCAL` (and `tracePath` with `--trace-path`), installs the hooks, and contacts no control plane. Commit `.chainloop.yml` to share the choice. -- **Back to the control plane:** run `chainloop trace init` (without `--local`). It writes `traceStorage: CONTROL_PLANE`, clearing the local policy, and sets up the workflow as usual. The command tells you it switched. Commit `.chainloop.yml`. +- **To local:** run `chainloop trace init --local`. It writes `traceStorage: LOCAL`, and `tracePath` when you give `--trace-path`. It installs the hooks and does not connect to a control plane. Commit `.chainloop.yml` to share the change. +- **Back to the control plane:** run `chainloop trace init` without `--local`. It writes `traceStorage: CONTROL_PLANE` and sets up the workflow. The command tells you about the change. Commit `.chainloop.yml`. -Run `chainloop trace init` once per repository. The git hooks live under `.git` and are not cloned, but in a clone the first traced session installs them, so a teammate does not run init. +Run `chainloop trace init` one time for each repository. Git does not clone the git hooks in `.git`. In a clone, the first traced session installs them, so a teammate does not run init. ## 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 05c9bd281..1ce2415d1 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -106,7 +106,7 @@ func ResolveTraceStorage(persisted string) (string, error) { case TraceStorageControlPlane, TraceStorageLocal: return v, nil default: - return "", fmt.Errorf("unknown traceStorage %q in .chainloop.yml: want %s or %s", persisted, TraceStorageControlPlane, TraceStorageLocal) + return "", fmt.Errorf("unknown traceStorage %q in .chainloop.yml: use %s or %s", persisted, TraceStorageControlPlane, TraceStorageLocal) } } diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 25cd6ac18..30abc921f 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -164,7 +164,7 @@ func localSessionStartBanner(tracePath string, traceRunActive bool) string { if traceRunActive { when = "when this run finishes" } - banner += "\nIts evidence will be stored on disk at " + tracePath + " " + when + "." + banner += "\nChainloop Trace writes the session evidence to " + tracePath + " " + when + "." } return banner diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index ce1292149..89612717d 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -558,7 +558,7 @@ func HandlePrePushHookLocal(ctx context.Context, log zerolog.Logger) error { } if stored == 0 { - log.Info().Msg("no new AI coding session evidence, nothing stored") + log.Info().Msg("no new AI coding session evidence to store") return nil } @@ -608,7 +608,7 @@ func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts return err } if len(sessions) == 0 { - log.Info().Msg("no new AI coding session evidence, skipping attestation") + log.Info().Msg("no new AI coding session evidence to attest") return nil } diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index c471f94d2..53abe10bc 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -157,7 +157,7 @@ func storeSessionsLocally(ctx context.Context, log zerolog.Logger, store *state. // 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") + log.Warn().Str("session", se.sessionID).Msg("could not store the evidence of this session") continue } diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index 975780767..e295cb73a 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -193,7 +193,7 @@ func TestLocalSessionStartBanner(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") + assert.NotContains(t, got, "writes the session evidence") }) } diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index 5d9c71eda..082cdc9df 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -209,7 +209,7 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error 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") + log.Warn().Msg("trace recorded no AI coding session, so it stored nothing") default: log.Info().Int("sessions", stored).Msgf("AI coding session evidence stored at %s", tracePath) } From 69e74fc6e1f98d0cec257f0793a1c485004074e5 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 13:43:46 +0200 Subject: [PATCH 12/19] feat(trace): suggest local mode when trace init is not authenticated When a control-plane trace init fails because the user is not authenticated, add a tip to the error: run "chainloop trace init --local" to configure the repository in local mode. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/trace_init.go | 26 ++++++++++++-- app/cli/cmd/trace_init_local_test.go | 51 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index a53d49740..d270e6417 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -142,12 +142,12 @@ cannot use them with --local. You can use --require-trace with --local.`, var executor *action.AttestationExecutor if !localFlag { if err := ensureActionOpts(cmd); err != nil { - return err + return withLocalModeTip(err) } executor, err = resolveTraceIdentity(cmd.Context(), cfg, repoRoot) if err != nil { - return stopIfAborted(err) + return withLocalModeTip(stopIfAborted(err)) } defer func() { _ = executor.Close() }() } @@ -170,7 +170,7 @@ cannot use them with --local. You can use --require-trace with --local.`, // 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 + return withLocalModeTip(err) } } @@ -294,6 +294,26 @@ cannot use them with --local. You can use --require-trace with --local.`, return cmd } +// localModeTip is added to a control-plane init error when the user is not +// authenticated, because local mode needs no login. +const localModeTip = `Tip: run "chainloop trace init --local" to configure it in local mode` + +// withLocalModeTip adds localModeTip to an authentication error. The message +// keeps the text the CLI shows for that error everywhere else. Any other error +// is returned unchanged. +func withLocalModeTip(err error) error { + if err == nil { + return nil + } + + msg, ok := action.AuthErrorMessage(err) + if !ok { + return err + } + + return fmt.Errorf("%s\n%s", msg, localModeTip) +} + // traceDocsURL is the guide covering what this command set up and what can be // done with it, which is more than belongs in a command's own output. const traceDocsURL = "https://docs.chainloop.dev/ai-sessions" diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go index db6ea3269..2235841e5 100644 --- a/app/cli/cmd/trace_init_local_test.go +++ b/app/cli/cmd/trace_init_local_test.go @@ -17,6 +17,8 @@ package cmd import ( "bytes" + "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -24,8 +26,11 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" + jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func gitInit(t *testing.T, dir string) { @@ -234,3 +239,49 @@ func TestTraceStorageToPersist(t *testing.T) { }) } } + +// TestWithLocalModeTip covers the tip a control-plane init adds when the user +// is not authenticated: local mode needs no login. +func TestWithLocalModeTip(t *testing.T) { + const tip = `Tip: run "chainloop trace init --local" to configure it in local mode` + + testCases := []struct { + name string + err error + want string + wantTip bool + }{ + { + name: "missing token", + err: fmt.Errorf("resolve organization: %w", status.Error(codes.Unauthenticated, jwtMiddleware.ErrMissingJwtToken.Message)), + want: `authentication required, please run "chainloop auth login"`, + wantTip: true, + }, + { + name: "expired token", + err: status.Error(codes.Unauthenticated, jwtMiddleware.ErrTokenExpired.Message), + want: `your authentication token has expired, please run "chainloop auth login" again`, + wantTip: true, + }, + { + name: "any other error is unchanged", + err: errors.New("boom"), + want: "boom", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := withLocalModeTip(tc.err) + require.Error(t, got) + assert.Contains(t, got.Error(), tc.want) + if tc.wantTip { + assert.Contains(t, got.Error(), tip) + } else { + assert.Equal(t, tc.err, got) + } + }) + } + + assert.NoError(t, withLocalModeTip(nil)) +} From de543f399f8a91ac15966b9922bbeb617791e3c9 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 14:36:19 +0200 Subject: [PATCH 13/19] feat(trace): make tracePath the trace directory in every mode --trace-path no longer turns on local mode. tracePath in .chainloop.yml now sets the directory where Chainloop Trace keeps its data, in local and control-plane mode. The default stays .git/chainloop-trace, and a relative path starts at the repository root. Local mode writes the sessions to the local-sessions folder of that directory. Every git store of a repository now comes from state.NewRepoStore, so the git hooks, the agent hooks, the pre-push hook, trace run and trace uninstall all use the same directory. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/trace_init.go | 44 ++++----- app/cli/cmd/trace_init_local_test.go | 29 +++++- app/cli/cmd/trace_run.go | 2 +- app/cli/cmd/trace_uninstall.go | 2 +- app/cli/documentation/cli-reference.md | 14 +-- app/cli/internal/repositoryconfig/config.go | 6 +- app/cli/internal/trace/README.md | 6 +- app/cli/internal/trace/config/config.go | 11 --- app/cli/internal/trace/config/storage_test.go | 2 +- app/cli/internal/trace/state/locate.go | 2 +- app/cli/internal/trace/state/state.go | 60 ++----------- app/cli/internal/trace/state/state_test.go | 89 ++++++++++++++----- app/cli/internal/trace/state/store.go | 45 +++++++++- app/cli/pkg/action/trace_agent_hook.go | 2 +- app/cli/pkg/action/trace_hook_handler.go | 8 +- app/cli/pkg/action/trace_local_store.go | 17 +--- app/cli/pkg/action/trace_local_store_test.go | 11 --- app/cli/pkg/action/trace_run.go | 7 +- 18 files changed, 194 insertions(+), 163 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index d270e6417..e968fba70 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -82,10 +82,14 @@ The organization, project, workflow and require-trace values are saved to With --local, init sets up the repository for local mode. It installs the same hooks, but it creates no workflow, needs no login, and does not connect to a -control plane. It writes traceStorage: LOCAL to .chainloop.yml. It also writes -tracePath when you give --trace-path. Every clone then records sessions on -disk. On each push, the pre-push hook writes the session evidence to that path. -It does not send an attestation. +control plane. It writes traceStorage: LOCAL to .chainloop.yml. Every clone +then records sessions on disk. On each push, the pre-push hook writes the +session evidence to the local-sessions folder of the trace directory. It does +not send an attestation. + +--trace-path sets the trace directory, where Chainloop Trace keeps its data. +The default is .git/chainloop-trace. It works in every mode, and init writes it +to .chainloop.yml as tracePath. A relative path starts at the repository root. --contract, --workflow, --org and --project are control-plane settings. You cannot use them with --local. You can use --require-trace with --local.`, @@ -95,12 +99,6 @@ cannot use them with --local. You can use --require-trace with --local.`, 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. @@ -118,7 +116,7 @@ cannot use them with --local. You can use --require-trace with --local.`, } cfg.local = localFlag cfg.tracePath = tracePathFlag - cfg.saveTracePath = localFlag && tracePathFlag != "" + cfg.saveTracePath = 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 @@ -255,7 +253,12 @@ cannot use them with --local. You can use --require-trace with --local.`, // 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)) + sessionsDir := state.NewRepoStore(gitDir, repoRoot).LocalSessionsDir() + if rel, err := filepath.Rel(workDir, sessionsDir); err == nil { + sessionsDir = rel + } + + writeTraceInitLocalSummary(os.Stdout, sessionsDir, providerNames(installed)) writeTraceLocalNextSteps(os.Stdout, repoRoot, workDir, installed) return nil @@ -284,7 +287,7 @@ cannot use them with --local. You can use --require-trace with --local.`, cmd.Flags().StringVar(&project, "project", "", "chainloop project name") cmd.Flags().StringVar(&contract, "contract", "", traceContractFlagDesc) cmd.Flags().BoolVar(&localFlag, "local", false, "set up local mode, with no control plane, login or workflow. The pre-push hook writes the session evidence to disk") - cmd.Flags().StringVar(&tracePathFlag, "trace-path", "", "the directory where local mode writes the sessions (default: .git/chainloop-trace/local-sessions). This flag also sets --local") + cmd.Flags().StringVar(&tracePathFlag, "trace-path", "", "the directory where Chainloop Trace keeps its data, in every mode (default: .git/chainloop-trace). Local mode writes the sessions to its local-sessions folder") 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 a required trace is missing") cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)") @@ -381,15 +384,13 @@ Learn more: %s // 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) { +// +// sessionsDir is the directory the sessions are written to, as the user should +// read it. +func writeTraceInitLocalSummary(w io.Writer, sessionsDir 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 written to %s on each push\n", path) - } else { - fmt.Fprint(w, " sessions written to .git/chainloop-trace/local-sessions on each push\n") - } + fmt.Fprintf(w, " sessions written to %s on each push\n", sessionsDir) fmt.Fprintf(w, " harnesses %s\n", strings.Join(harnesses, ", ")) } @@ -562,7 +563,8 @@ func writeTraceInitState(cfg *traceInitConfig, repoRoot, gitDir string) error { return err } - store := state.NewGitStore(gitDir) + // Read after cfg.save, so a tracePath written on this run applies. + store := state.NewRepoStore(gitDir, repoRoot) if err := store.InitTraceDir(); err != nil { return fmt.Errorf("create trace directory: %w", err) } diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go index 2235841e5..72981fd2f 100644 --- a/app/cli/cmd/trace_init_local_test.go +++ b/app/cli/cmd/trace_init_local_test.go @@ -75,17 +75,37 @@ func TestTraceInitLocal(t *testing.T) { assert.NoError(t, err) }) - t.Run("--trace-path implies local and is written", func(t *testing.T) { + t.Run("--trace-path is written and moves the trace state", func(t *testing.T) { repoDir := t.TempDir() gitInit(t, repoDir) - require.NoError(t, runTraceInit(t, repoDir, "--trace-path", "evidence/sessions", "--claude")) + require.NoError(t, runTraceInit(t, repoDir, "--local", "--trace-path", ".trace-state", "--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") + assert.Contains(t, got, "tracePath: .trace-state") + + // The trace state is in the tracePath, not in .git/chainloop-trace. + _, err = os.Stat(filepath.Join(repoDir, ".trace-state")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(repoDir, ".git", "chainloop-trace")) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("--trace-path does not turn on local mode", func(t *testing.T) { + // An invalid traceStorage stops a control-plane init before it + // connects, and --local overrides it. So the error shows that + // --trace-path alone left the init in control-plane mode. + repoDir := t.TempDir() + gitInit(t, repoDir) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, chainloopYMLName), + []byte("traceStorage: bogus\n"), 0600)) + + err := runTraceInit(t, repoDir, "--trace-path", ".trace-state", "--claude") + require.Error(t, err) + assert.Contains(t, err.Error(), "traceStorage") }) t.Run("--require-trace is allowed and written as traceRequired", func(t *testing.T) { @@ -162,9 +182,10 @@ func TestWriteTraceStorageSwitchedNote(t *testing.T) { 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 + writeTraceInitLocalSummary(out, ".trace-state/local-sessions", []string{providerClaudeCode, "cursor"}) //nolint:goconst // provider wire name, kept literal like the other provider tests got := out.String() + assert.Contains(t, got, "written to .trace-state/local-sessions on each push") assert.Contains(t, got, "local mode") assert.Contains(t, got, "no control plane") assert.Contains(t, got, "claude-code, cursor") diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 2688967a4..2421dfa97 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -96,7 +96,7 @@ func newTraceRunCmd() *cobra.Command { store, repoRoot = state.NewOutOfTreeStore(stateDir), cwd logger.Info().Msg("no git repository found; recording the session without commit attribution") default: - store = state.NewGitStore(gitDir) + store = state.NewRepoStore(gitDir, repoRoot) } // The storage mode is a repository policy, read even though trace diff --git a/app/cli/cmd/trace_uninstall.go b/app/cli/cmd/trace_uninstall.go index a50dd0188..e84687bba 100644 --- a/app/cli/cmd/trace_uninstall.go +++ b/app/cli/cmd/trace_uninstall.go @@ -45,7 +45,7 @@ func newTraceUninstallCmd() *cobra.Command { } repoRoot, _ := tracegit.RepoRoot() - if err := action.CleanupTrace(state.NewGitStore(gitDir), repoRoot, logger); err != nil { + if err := action.CleanupTrace(state.NewRepoStore(gitDir, repoRoot), repoRoot, logger); err != nil { return err } logger.Info().Msg("trace uninstalled") diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 07d25238e..542b03ca8 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -3324,10 +3324,14 @@ The organization, project, workflow and require-trace values are saved to With --local, init sets up the repository for local mode. It installs the same hooks, but it creates no workflow, needs no login, and does not connect to a -control plane. It writes traceStorage: LOCAL to .chainloop.yml. It also writes -tracePath when you give --trace-path. Every clone then records sessions on -disk. On each push, the pre-push hook writes the session evidence to that path. -It does not send an attestation. +control plane. It writes traceStorage: LOCAL to .chainloop.yml. Every clone +then records sessions on disk. On each push, the pre-push hook writes the +session evidence to the local-sessions folder of the trace directory. It does +not send an attestation. + +--trace-path sets the trace directory, where Chainloop Trace keeps its data. +The default is .git/chainloop-trace. It works in every mode, and init writes it +to .chainloop.yml as tracePath. A relative path starts at the repository root. --contract, --workflow, --org and --project are control-plane settings. You cannot use them with --local. You can use --require-trace with --local. @@ -3347,7 +3351,7 @@ Options --opencode install opencode hooks --project string chainloop project name --require-trace block pushes when a required trace is missing ---trace-path string the directory where local mode writes the sessions (default: .git/chainloop-trace/local-sessions). This flag also sets --local +--trace-path string the directory where Chainloop Trace keeps its data, in every mode (default: .git/chainloop-trace). Local mode writes the sessions to its local-sessions folder --workflow string chainloop workflow name used for trace attestations (defaults to "ai-coding-session") ``` diff --git a/app/cli/internal/repositoryconfig/config.go b/app/cli/internal/repositoryconfig/config.go index 9382d7061..0b0b99c11 100644 --- a/app/cli/internal/repositoryconfig/config.go +++ b/app/cli/internal/repositoryconfig/config.go @@ -46,8 +46,10 @@ type ChainloopYML struct { // 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 names the trace state directory, where Chainloop Trace keeps + // its data in every storage mode. LOCAL mode writes the sessions to its + // local-sessions folder. A relative path resolves against the repository + // root. Empty means /chainloop-trace. TracePath string `yaml:"tracePath,omitempty"` // TraceRequired blocks a push when a required trace is missing. It replaces // the deprecated RequireTrace with the same meaning. diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 650c6fa6c..ac20dbf43 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -100,15 +100,17 @@ How the sessions are linked to the commits and attested. |---------|---------------|-------------|--------|--------------|------------| | Commit trailer | Each commit gets a `Chainloop-Trace-Sessions` trailer with the sessions that contributed to it. | Yes | Yes | Yes | Yes | | Attestation at push | 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 as an attestation. It writes the session evidence set to `tracePath` (default `.git/chainloop-trace/local-sessions`) and sends no attestation. It needs no control plane, login, or network. The stored set is equal to the upload of a push, except for the signature. A `manifest.json` file marks the set as unsigned and not an attestation. `trace init --local` sets up the mode, and `trace run` uses it too. Redaction is on by default. Set `CHAINLOOP_TRACE_NO_REDACT` to turn it off, for a trusted local store only (spec issue-3582). | 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 as an attestation. It writes the session evidence set to the `local-sessions` folder of the trace directory and sends no attestation. It needs no control plane, login, or network. The stored set is equal to the upload of a push, except for the signature. A `manifest.json` file marks the set as unsigned and not an attestation. `trace init --local` sets up the mode, and `trace run` uses it too. Redaction is on by default. Set `CHAINLOOP_TRACE_NO_REDACT` to turn it off, for a trusted local store only (spec issue-3582). | Yes | Yes | Yes | Yes | ### Switching storage modes `traceStorage` in `.chainloop.yml` is a committed repository policy. It is the same for every clone. When a repository uses `LOCAL`, Trace stores every session of every user on disk. To change the mode: -- **To local:** run `chainloop trace init --local`. It writes `traceStorage: LOCAL`, and `tracePath` when you give `--trace-path`. It installs the hooks and does not connect to a control plane. Commit `.chainloop.yml` to share the change. +- **To local:** run `chainloop trace init --local`. It writes `traceStorage: LOCAL`, installs the hooks, and does not connect to a control plane. Commit `.chainloop.yml` to share the change. - **Back to the control plane:** run `chainloop trace init` without `--local`. It writes `traceStorage: CONTROL_PLANE` and sets up the workflow. The command tells you about the change. Commit `.chainloop.yml`. +`tracePath` in `.chainloop.yml` sets the trace directory, where Chainloop Trace keeps its data in every mode. The default is `.git/chainloop-trace`. A relative path starts at the repository root. `chainloop trace init --trace-path ` writes it. A directory in the working tree must be in `.gitignore`. + Run `chainloop trace init` one time for each repository. Git does not clone the git hooks in `.git`. In a clone, the first traced session installs them, so a teammate does not run init. ## 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 1ce2415d1..12d1e3447 100644 --- a/app/cli/internal/trace/config/config.go +++ b/app/cli/internal/trace/config/config.go @@ -150,17 +150,6 @@ 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 { diff --git a/app/cli/internal/trace/config/storage_test.go b/app/cli/internal/trace/config/storage_test.go index db26679e8..af1a6c146 100644 --- a/app/cli/internal/trace/config/storage_test.go +++ b/app/cli/internal/trace/config/storage_test.go @@ -106,12 +106,12 @@ func TestTraceStorageRoundTrip(t *testing.T) { 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), "tracePath: evidence/sessions") assert.Contains(t, string(data), "projectVersion: v1.0.0") } diff --git a/app/cli/internal/trace/state/locate.go b/app/cli/internal/trace/state/locate.go index 8add81645..0c2d0b961 100644 --- a/app/cli/internal/trace/state/locate.go +++ b/app/cli/internal/trace/state/locate.go @@ -58,7 +58,7 @@ func LocateFrom(start string) (store *Store, root string, err error) { gitDir, repoRoot, err := tracegit.FindGitDirAndRootFrom(start) switch { case err == nil: - return NewGitStore(gitDir), repoRoot, nil + return NewRepoStore(gitDir, repoRoot), repoRoot, nil case !errors.Is(err, tracegit.ErrNotARepository): return nil, "", err } diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go index 93ba9c17e..be581e0a8 100644 --- a/app/cli/internal/trace/state/state.go +++ b/app/cli/internal/trace/state/state.go @@ -105,10 +105,10 @@ 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. +// LocalSessionsDir returns the directory LOCAL trace storage writes its +// evidence sets to. It sits beside the other trace state in the trace directory +// (.git/chainloop-trace unless tracePath moves it), 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) } @@ -347,24 +347,16 @@ func (s *Store) staleSessionIDs(unattested map[string]struct{}) map[string]struc return stale } -// 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 +// RemoveTraceDir removes the trace state but preserves local-sessions/, 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. 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 { +func (s *Store) RemoveTraceDir() 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 { @@ -393,40 +385,6 @@ func (s *Store) RemoveTraceDir(preservePaths ...string) error { 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. func (s *Store) LogFilePath() string { return filepath.Join(s.traceDirPath(), "log.txt") diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index e22c9f2bb..853988546 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -142,30 +142,73 @@ func TestRemoveTraceDirPreservesLocalSessions(t *testing.T) { _, err := os.Stat(plain.LocalSessionsDir()) assert.True(t, os.IsNotExist(err)) }) +} + +// TestNewRepoStore covers tracePath in .chainloop.yml: it moves the whole +// trace state directory, in every storage mode. A relative path resolves +// against the repository root. With no tracePath, the state stays in +// .git/chainloop-trace. +func TestNewRepoStore(t *testing.T) { + testCases := []struct { + name string + yml string + // want returns the expected trace directory for the repository root + // and the .git directory. + want func(repoRoot, gitDir string) string + }{ + { + name: "no config keeps the default", + want: func(_, gitDir string) string { return filepath.Join(gitDir, "chainloop-trace") }, + }, + { + name: "no tracePath keeps the default", + yml: "projectName: p\n", + want: func(_, gitDir string) string { return filepath.Join(gitDir, "chainloop-trace") }, + }, + { + name: "a relative tracePath resolves against the repository root", + yml: "tracePath: .trace-state\n", + want: func(repoRoot, _ string) string { return filepath.Join(repoRoot, ".trace-state") }, + }, + { + name: "an absolute tracePath is used as is", + yml: "tracePath: /var/lib/trace\n", + want: func(_, _ string) string { return "/var/lib/trace" }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + repoRoot := t.TempDir() + gitDir := filepath.Join(repoRoot, ".git") + require.NoError(t, os.MkdirAll(gitDir, 0o700)) + if tc.yml != "" { + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, ".chainloop.yml"), []byte(tc.yml), 0o600)) + } + + store := NewRepoStore(gitDir, repoRoot) + want := tc.want(repoRoot, gitDir) + assert.Equal(t, want, store.TraceDir()) + assert.Equal(t, filepath.Join(want, "local-sessions"), store.LocalSessionsDir()) + assert.Equal(t, gitDir, store.GitDir(), "the git hooks stay in the .git directory") + }) + } - 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") + t.Run("the state is written under the tracePath", func(t *testing.T) { + repoRoot := t.TempDir() + gitDir := filepath.Join(repoRoot, ".git") + require.NoError(t, os.MkdirAll(gitDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, ".chainloop.yml"), []byte("tracePath: .trace-state\n"), 0o600)) + + store := NewRepoStore(gitDir, repoRoot) + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.MarkTraceInitialized()) + + _, err := os.Stat(filepath.Join(repoRoot, ".trace-state")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(gitDir, "chainloop-trace")) + assert.True(t, os.IsNotExist(err), "nothing is written to the default directory") + assert.True(t, NewRepoStore(gitDir, repoRoot).IsTraceInitialized()) }) } diff --git a/app/cli/internal/trace/state/store.go b/app/cli/internal/trace/state/store.go index efd378d6d..1f39991a7 100644 --- a/app/cli/internal/trace/state/store.go +++ b/app/cli/internal/trace/state/store.go @@ -15,7 +15,11 @@ package state -import "path/filepath" +import ( + "path/filepath" + + "github.com/chainloop-dev/chainloop/app/cli/internal/repositoryconfig" +) // Store reads and writes the chainloop-trace state under a single parent // directory: the .git directory inside a repository, or the out-of-tree @@ -26,6 +30,9 @@ import "path/filepath" type Store struct { dir string isGit bool + // traceDir, when set, replaces /chainloop-trace as the trace state + // directory. It comes from tracePath in .chainloop.yml. + traceDir string } // NewGitStore returns a Store over a repository's .git directory. @@ -34,6 +41,30 @@ func NewGitStore(gitDir string) *Store { return &Store{dir: gitDir, isGit: true} } +// NewRepoStore returns a Store over a repository's .git directory that honors +// tracePath in the repository's .chainloop.yml. tracePath moves the trace state +// directory, in every storage mode. A relative path resolves against repoRoot. +// With no tracePath, the state stays in /chainloop-trace. The git hooks +// stay in gitDir either way. +// +// Every git store of a repository must come from here, so the hooks, the +// pre-push and the agent hooks all read and write the same directory. +func NewRepoStore(gitDir, repoRoot string) *Store { + store := NewGitStore(gitDir) + + cfg := repositoryconfig.FindChainloopYML(repoRoot) + if cfg == nil || cfg.TracePath == "" { + return store + } + + store.traceDir = cfg.TracePath + if !filepath.IsAbs(store.traceDir) { + store.traceDir = filepath.Join(repoRoot, store.traceDir) + } + + return store +} + // NewOutOfTreeStore returns a Store over the directory that holds trace state // outside a repository, as returned by NonGitDir. func NewOutOfTreeStore(dir string) *Store { @@ -65,7 +96,17 @@ func (s *Store) Dir() string { return s.dir } -// traceDirPath returns the path to /chainloop-trace/. +// TraceDir returns the trace state directory: tracePath when the repository +// sets one, otherwise /chainloop-trace. +func (s *Store) TraceDir() string { + return s.traceDirPath() +} + +// traceDirPath returns the trace state directory. See TraceDir. func (s *Store) traceDirPath() string { + if s.traceDir != "" { + return s.traceDir + } + return filepath.Join(s.dir, traceDir) } diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 30abc921f..228825f1f 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -211,7 +211,7 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error // 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.LocalSessionsDir(), store.IsTraceRunActive(), ) } else { diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index 89612717d..30a1831df 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -87,7 +87,7 @@ func handleCommitMsg(msgFilePath string, log zerolog.Logger) error { log.Debug().Strs("session_ids", sessionIDs).Msg("amending HEAD, keeping its trace sessions") } - attrs, err := state.NewGitStore(gitDir).LoadAllAILineAttributions() + attrs, err := state.NewRepoStore(gitDir, repoRoot).LoadAllAILineAttributions() if err != nil { return fmt.Errorf("load AI line attributions: %w", err) } @@ -266,7 +266,7 @@ func handlePostCommit(_ context.Context, log zerolog.Logger) error { // Prefer the trailer's session IDs when present — they survive rebase // and cherry-pick verbatim. Fall back to file-based derivation otherwise. - store := state.NewGitStore(gitDir) + store := state.NewRepoStore(gitDir, repoRoot) sessionIDs := state.ParseSessionIDsFromTrailer(message) if len(sessionIDs) == 0 { @@ -327,14 +327,14 @@ func HandlePostRewriteHook(_ context.Context, log zerolog.Logger) error { // Only records that post-commit already wrote are updated: the rewrite hook // carries sessions forward, it does not decide which commits are recorded. func handlePostRewrite(r io.Reader, log zerolog.Logger) error { - gitDir, _, err := tracegit.FindGitDirAndRoot() + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() if err != nil { return err } log.Debug().Str("git_dir", gitDir).Msg("post-rewrite hook invoked") - store := state.NewGitStore(gitDir) + store := state.NewRepoStore(gitDir, repoRoot) records, err := store.LoadAllCommitRecords() if err != nil { return fmt.Errorf("load commit records: %w", err) diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index 53abe10bc..b77bdc01b 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -25,7 +25,6 @@ import ( "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" @@ -89,7 +88,7 @@ func RunTraceLocalStore(ctx context.Context, log zerolog.Logger, opts RunTraceLo return 0, "", err } - tracePath = ResolveTracePath(repoRoot, store, config.LoadTracePathFromYML(repoRoot)) + tracePath = store.LocalSessionsDir() sessionMode := aicodingsession.ResolveMode(opts.Mode) log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot). @@ -123,20 +122,6 @@ func RunTraceLocalStore(ctx context.Context, log zerolog.Logger, opts RunTraceLo 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 diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index e295cb73a..84ed88eb8 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -158,17 +158,6 @@ func TestRunTraceLocalStoreNoSessions(t *testing.T) { 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) diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index 082cdc9df..ed993c990 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -24,7 +24,6 @@ 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" @@ -415,11 +414,7 @@ func CleanupTrace(store *state.Store, repoRoot string, log zerolog.Logger) error } } - // 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 { + if err := store.RemoveTraceDir(); err != nil { log.Warn().Err(err).Msg("could not remove trace directory") errs = append(errs, fmt.Errorf("remove trace directory: %w", err)) } From 33bda241155bdd697e48bfb7f6ec1295d8fcd2de Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 14:52:35 +0200 Subject: [PATCH 14/19] fix(trace): report a partial local store and qualify the mode help - A local store that skips a session now returns an error, so the pre-push hook warns, or blocks the push when traceRequired is set. - trace init help: the workflow and the login apply to control-plane mode only. - trace run help: the identity flags are required in control-plane mode only, trace run also reads tracePath, and the workflow is created in control-plane mode only. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/trace_init.go | 7 +-- app/cli/cmd/trace_run.go | 20 ++++---- app/cli/documentation/cli-reference.md | 27 ++++++----- app/cli/pkg/action/trace_local_store.go | 6 +++ app/cli/pkg/action/trace_local_store_test.go | 49 ++++++++++++++++++++ 5 files changed, 85 insertions(+), 24 deletions(-) diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go index e968fba70..e75a18947 100644 --- a/app/cli/cmd/trace_init.go +++ b/app/cli/cmd/trace_init.go @@ -61,9 +61,10 @@ func newTraceInitCmd() *cobra.Command { create Chainloop attestations when you push. It installs the managed git hooks plus the hooks of the selected harnesses -(Claude Code when none is given), and creates the Chainloop workflow the -attestations target. Nothing is written to the repository until that workflow -exists, so you need to be logged in. +(Claude Code when none is given). In control-plane mode, it also creates the +Chainloop workflow the attestations target. Nothing is written to the +repository until that workflow exists, so you need to be logged in. Local mode +(--local) needs no workflow and no login. On a terminal it asks which organization and project to use, offering what .chainloop.yml already holds so pressing Enter keeps it. A new project can be diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 2421dfa97..2020716cd 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -32,21 +32,23 @@ Chainloop attestation for the session once the command exits successfully. Sessions without git commits are still attested. trace run does not read the attestation identity from .chainloop.yml. The ---org, --project and --workflow flags (mandatory) and the optional --version -flag set it. +--org, --project and --workflow flags and the optional --version flag set it. +The --org, --project and --workflow flags are required in control-plane mode. -trace run reads one value from .chainloop.yml: traceStorage. In a local-mode -repository (traceStorage: LOCAL), trace run writes the session to disk when the -command exits. It does not connect to a control plane, and the --org, --project -and --workflow flags are optional. +trace run reads two values from .chainloop.yml: traceStorage and the optional +tracePath. tracePath sets the trace directory. In a local-mode repository +(traceStorage: LOCAL), trace run writes the session to disk when the command +exits. It does not connect to a control plane, and the --org, --project and +--workflow flags are optional. When the wrapped command exits, trace run removes the temporary hooks and the trace state. It keeps the stored local session evidence. Thus no setup from one session stays for the next session. -The workflow is created before the command runs. That part is best-effort: the -session runs either way and the attestation creates the workflow if this did -not, except with --contract, which trace run cannot honor later.` +In control-plane mode, the workflow is created before the command runs. That +part is best-effort: the session runs either way and the attestation creates +the workflow if this did not, except with --contract, which trace run cannot +honor later.` // newTraceRunCmd creates the `trace run` subcommand. func newTraceRunCmd() *cobra.Command { diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 542b03ca8..0223e8479 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -3303,9 +3303,10 @@ Initialize git hooks that automatically trace AI coding sessions and create Chainloop attestations when you push. It installs the managed git hooks plus the hooks of the selected harnesses -(Claude Code when none is given), and creates the Chainloop workflow the -attestations target. Nothing is written to the repository until that workflow -exists, so you need to be logged in. +(Claude Code when none is given). In control-plane mode, it also creates the +Chainloop workflow the attestations target. Nothing is written to the +repository until that workflow exists, so you need to be logged in. Local mode +(--local) needs no workflow and no login. On a terminal it asks which organization and project to use, offering what .chainloop.yml already holds so pressing Enter keeps it. A new project can be @@ -3383,21 +3384,23 @@ Chainloop attestation for the session once the command exits successfully. Sessions without git commits are still attested. trace run does not read the attestation identity from .chainloop.yml. The ---org, --project and --workflow flags (mandatory) and the optional --version -flag set it. +--org, --project and --workflow flags and the optional --version flag set it. +The --org, --project and --workflow flags are required in control-plane mode. -trace run reads one value from .chainloop.yml: traceStorage. In a local-mode -repository (traceStorage: LOCAL), trace run writes the session to disk when the -command exits. It does not connect to a control plane, and the --org, --project -and --workflow flags are optional. +trace run reads two values from .chainloop.yml: traceStorage and the optional +tracePath. tracePath sets the trace directory. In a local-mode repository +(traceStorage: LOCAL), trace run writes the session to disk when the command +exits. It does not connect to a control plane, and the --org, --project and +--workflow flags are optional. When the wrapped command exits, trace run removes the temporary hooks and the trace state. It keeps the stored local session evidence. Thus no setup from one session stays for the next session. -The workflow is created before the command runs. That part is best-effort: the -session runs either way and the attestation creates the workflow if this did -not, except with --contract, which trace run cannot honor later. +In control-plane mode, the workflow is created before the command runs. That +part is best-effort: the session runs either way and the attestation creates +the workflow if this did not, except with --contract, which trace run cannot +honor later. ``` chainloop trace run -- [args...] [flags] diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index b77bdc01b..4d842d2f5 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -119,6 +119,12 @@ func RunTraceLocalStore(ctx context.Context, log zerolog.Logger, opts RunTraceLo finalizePush(store, repoRoot, aiCommits, log) } + // A skipped session is a failure, so the pre-push hook can warn, or block + // the push when traceRequired is set. + if stored < len(sessions) { + return stored, tracePath, fmt.Errorf("stored %d of %d AI coding sessions, the next push tries the others again", stored, len(sessions)) + } + return stored, tracePath, nil } diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index 84ed88eb8..7db180b44 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -203,3 +203,52 @@ func materialByName(t *testing.T, m localManifest, name string) storedMaterial { require.Failf(t, "material not found", "material %q not found in manifest", name) return storedMaterial{} } + +// TestRunTraceLocalStorePartialFailure covers a session that cannot be stored. +// The store returns an error, so the pre-push hook can warn, or block the push +// when traceRequired is set. The commit stays untracked, so the next push +// tries the session again. +func TestRunTraceLocalStorePartialFailure(t *testing.T) { + const sessionID = "5d2b9e7a-1c3f-4a6b-8d9e-0f1a2b3c4d5e" + + home := t.TempDir() + t.Setenv("HOME", home) + + repoDir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + t.Chdir(repoDir) + + projectDir := claudeProjectDir(home, repoDir) + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + tr := transcript{t: t, path: filepath.Join(projectDir, sessionID+".jsonl")} + tr.append(`{"type":"user","uuid":"u1","timestamp":"2026-10-15T10:00:00.000Z","sessionId":"` + sessionID + `","message":{"role":"user","content":"Add a hello world."}}`) + + payload, err := json.Marshal(struct { + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + }{sessionID, "SessionStart", tr.path, repoDir}) + require.NoError(t, err) + withStdin(t, string(payload)) + captureStdout(t, func() { require.NoError(t, HandleAgentSessionStart(claude.New(), zerolog.Nop())) }) + + sha := headSHA(t, repoDir) + require.NoError(t, store.SaveCommitRecord(&state.CommitRecord{ + SHA: sha, + Message: "initial", + SessionIDs: []string{sessionID}, + Timestamp: state.NowTimestamp(), + })) + + // A file where the session directory must go makes the store of this + // session fail. + require.NoError(t, os.MkdirAll(store.LocalSessionsDir(), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(store.LocalSessionsDir(), state.SanitizeID(sessionID)), nil, 0o600)) + + stored, _, err := RunTraceLocalStore(t.Context(), zerolog.Nop(), RunTraceLocalStoreOpts{Mode: aicodingsession.ModeCoding}) + require.ErrorContains(t, err, "stored 0 of 1") + assert.Zero(t, stored) + assert.False(t, mustLoadRecord(t, store, sha).Tracked, "the commit stays for the next push") +} From 2ac1bd6c868eeedd93ef03bd567131722103949c Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 16:04:55 +0200 Subject: [PATCH 15/19] feat(trace): add --local to trace run trace run takes its settings from its flags, so local mode is now the --local flag instead of traceStorage in .chainloop.yml. With --local, the control-plane flags (--org, --project, --workflow, --version, --contract) are rejected. The run marks itself local in the trace state, so the session-start banner shows the mode of the run. trace run still uses tracePath when the repository sets one, because the agent hooks keep the session there. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/trace_run.go | 45 +++++++++----- app/cli/cmd/trace_run_local_test.go | 69 +++++++++++++++++++++ app/cli/documentation/cli-reference.md | 24 ++++--- app/cli/internal/trace/README.md | 2 +- app/cli/internal/trace/state/state.go | 39 ++++++++++-- app/cli/internal/trace/state/state_test.go | 16 +++++ app/cli/pkg/action/trace_agent_hook.go | 9 ++- app/cli/pkg/action/trace_agent_hook_test.go | 41 ++++++++++++ app/cli/pkg/action/trace_run.go | 14 ++++- 9 files changed, 221 insertions(+), 38 deletions(-) create mode 100644 app/cli/cmd/trace_run_local_test.go diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go index 2020716cd..02bed925e 100644 --- a/app/cli/cmd/trace_run.go +++ b/app/cli/cmd/trace_run.go @@ -31,15 +31,18 @@ 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 does not read the attestation identity from .chainloop.yml. The ---org, --project and --workflow flags and the optional --version flag set it. -The --org, --project and --workflow flags are required in control-plane mode. +trace run takes its settings from its flags, not from .chainloop.yml. The +--org, --project and --workflow flags and the optional --version flag set the +attestation identity. The --org, --project and --workflow flags are required in +control-plane mode. -trace run reads two values from .chainloop.yml: traceStorage and the optional -tracePath. tracePath sets the trace directory. In a local-mode repository -(traceStorage: LOCAL), trace run writes the session to disk when the command -exits. It does not connect to a control plane, and the --org, --project and ---workflow flags are optional. +With --local, trace run writes the session to disk when the command exits, +also when the command fails. It does not connect to a control plane. You +cannot use --org, --project, --workflow, --version or --contract with --local. + +The one value trace run takes from .chainloop.yml is tracePath, when the +repository sets one. The agent hooks keep the session in that directory, so +trace run must use it too. When the wrapped command exits, trace run removes the temporary hooks and the trace state. It keeps the stored local session evidence. Thus no setup from one @@ -57,6 +60,7 @@ func newTraceRunCmd() *cobra.Command { workflowFlag string versionFlag string contractFlag string + localFlag bool claudeFlag bool cursorFlag bool opencodeFlag bool @@ -101,14 +105,20 @@ func newTraceRunCmd() *cobra.Command { store = state.NewRepoStore(gitDir, repoRoot) } - // 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 err + // The mode comes from --local only. trace run does not read + // traceStorage from .chainloop.yml. + local := localFlag + + // Local mode contacts no control plane, so the control-plane + // identity flags are contradictions rather than extra + // configuration. + if local { + for _, name := range []string{"org", "project", "workflow", "version", "contract"} { //nolint:goconst // flag names, kept literal like the other flag references + if cmd.Flags().Changed(name) { + return fmt.Errorf("you cannot use --%s with --local: local mode does not connect to a control plane", name) + } + } } - local := storageMode == config.TraceStorageLocal var ( organization string @@ -170,10 +180,11 @@ func newTraceRunCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&projectFlag, "project", "", "chainloop project name (required, except in local mode). trace run does not read it from .chainloop.yml") - cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name for the trace attestations (required, except in local mode). trace run does not read it from .chainloop.yml") + cmd.Flags().StringVar(&projectFlag, "project", "", "chainloop project name (required, except with --local). trace run does not read it from .chainloop.yml") + cmd.Flags().StringVar(&workflowFlag, "workflow", "", "chainloop workflow name for the trace attestations (required, except with --local). trace run does not read it from .chainloop.yml") cmd.Flags().StringVar(&versionFlag, "version", "", "chainloop project version (optional; defaults to the latest version)") cmd.Flags().StringVar(&contractFlag, "contract", "", traceContractFlagDesc) + cmd.Flags().BoolVar(&localFlag, "local", false, "store the session on disk, with no control plane, login or workflow") 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") diff --git a/app/cli/cmd/trace_run_local_test.go b/app/cli/cmd/trace_run_local_test.go new file mode 100644 index 000000000..3ff2e6f5d --- /dev/null +++ b/app/cli/cmd/trace_run_local_test.go @@ -0,0 +1,69 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// runTraceRun executes `trace run` with the given args in dir. It returns the +// RunE error. +func runTraceRun(t *testing.T, dir string, args ...string) error { + t.Helper() + t.Chdir(dir) + + cmd := newTraceRunCmd() + cmd.Flags().String("org", "", "organization name") + cmd.SetArgs(args) + + return cmd.Execute() +} + +// TestTraceRunLocalFlag covers the --local flag of trace run. The flag alone +// selects local mode: trace run does not read traceStorage from .chainloop.yml. +func TestTraceRunLocalFlag(t *testing.T) { + t.Run("control-plane flags are rejected with --local", func(t *testing.T) { + for _, flag := range [][]string{ + {"--org", "acme"}, + {"--project", "my-project"}, + {"--workflow", "my-workflow"}, + {"--version", "v1.0.0"}, + {"--contract", "my-contract"}, + } { + repoDir := t.TempDir() + gitInit(t, repoDir) + args := append(append([]string{"--local"}, flag...), "--", "true") + err := runTraceRun(t, repoDir, args...) + assert.ErrorContains(t, err, "with --local", "flag %v", flag) + } + }) + + t.Run("traceStorage LOCAL in .chainloop.yml does not select local mode", func(t *testing.T) { + repoDir := t.TempDir() + gitInit(t, repoDir) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, chainloopYMLName), + []byte("traceStorage: LOCAL\n"), 0600)) + + // Without --local, the control-plane identity is required. + err := runTraceRun(t, repoDir, "--", "true") + assert.ErrorContains(t, err, "--org is required") + }) +} diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 0223e8479..ba01605fb 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -3383,15 +3383,18 @@ 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 does not read the attestation identity from .chainloop.yml. The ---org, --project and --workflow flags and the optional --version flag set it. -The --org, --project and --workflow flags are required in control-plane mode. +trace run takes its settings from its flags, not from .chainloop.yml. The +--org, --project and --workflow flags and the optional --version flag set the +attestation identity. The --org, --project and --workflow flags are required in +control-plane mode. -trace run reads two values from .chainloop.yml: traceStorage and the optional -tracePath. tracePath sets the trace directory. In a local-mode repository -(traceStorage: LOCAL), trace run writes the session to disk when the command -exits. It does not connect to a control plane, and the --org, --project and ---workflow flags are optional. +With --local, trace run writes the session to disk when the command exits, +also when the command fails. It does not connect to a control plane. You +cannot use --org, --project, --workflow, --version or --contract with --local. + +The one value trace run takes from .chainloop.yml is tracePath, when the +repository sets one. The agent hooks keep the session in that directory, so +trace run must use it too. When the wrapped command exits, trace run removes the temporary hooks and the trace state. It keeps the stored local session evidence. Thus no setup from one @@ -3413,10 +3416,11 @@ Options --contract string workflow contract to attach when creating the workflow; it must already exist and be usable by the project (default: "chainloop-ai-coding-session" when the organization has it, an empty contract otherwise) --cursor install Cursor hooks -h, --help help for run +--local store the session on disk, with no control plane, login or workflow --opencode install opencode hooks ---project string chainloop project name (required, except in local mode). trace run does not read it from .chainloop.yml +--project string chainloop project name (required, except with --local). trace run does not read it from .chainloop.yml --version string chainloop project version (optional; defaults to the latest version) ---workflow string chainloop workflow name for the trace attestations (required, except in local mode). trace run does not read it from .chainloop.yml +--workflow string chainloop workflow name for the trace attestations (required, except with --local). trace run does not read it from .chainloop.yml ``` Options inherited from parent commands diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index ac20dbf43..69032bdd6 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -100,7 +100,7 @@ How the sessions are linked to the commits and attested. |---------|---------------|-------------|--------|--------------|------------| | Commit trailer | Each commit gets a `Chainloop-Trace-Sessions` trailer with the sessions that contributed to it. | Yes | Yes | Yes | Yes | | Attestation at push | 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 as an attestation. It writes the session evidence set to the `local-sessions` folder of the trace directory and sends no attestation. It needs no control plane, login, or network. The stored set is equal to the upload of a push, except for the signature. A `manifest.json` file marks the set as unsigned and not an attestation. `trace init --local` sets up the mode, and `trace run` uses it too. Redaction is on by default. Set `CHAINLOOP_TRACE_NO_REDACT` to turn it off, for a trusted local store only (spec issue-3582). | 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 as an attestation. It writes the session evidence set to the `local-sessions` folder of the trace directory and sends no attestation. It needs no control plane, login, or network. The stored set is equal to the upload of a push, except for the signature. A `manifest.json` file marks the set as unsigned and not an attestation. `trace init --local` sets up the mode. `trace run --local` stores one wrapped session the same way, with no `.chainloop.yml` setting. Redaction is on by default. Set `CHAINLOOP_TRACE_NO_REDACT` to turn it off, for a trusted local store only (spec issue-3582). | Yes | Yes | Yes | Yes | ### Switching storage modes diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go index be581e0a8..fcfc78e5b 100644 --- a/app/cli/internal/trace/state/state.go +++ b/app/cli/internal/trace/state/state.go @@ -39,11 +39,14 @@ const ( // detect trace-run mode without depending on environment-variable // inheritance from the agent. traceRunActiveFile = "run-active" - traceDirSessions = "sessions" - traceDirRaw = "raw" - traceDirCommits = "commits" - traceDirSnapshots = "snapshots" - traceDirAILines = "ai-lines" + // traceRunLocalFile marks a `chainloop trace run --local` session, so + // the agent hooks know it stores its evidence on disk. + traceRunLocalFile = "run-local" + traceDirSessions = "sessions" + traceDirRaw = "raw" + 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 @@ -429,11 +432,35 @@ func (s *Store) MarkTraceRunActive() error { return os.WriteFile(filepath.Join(base, traceRunActiveFile), nil, 0600) } -// ClearTraceRunActive removes the trace-run-active sentinel. +// ClearTraceRunActive removes the trace-run-active sentinel and the local +// marker of the run. func (s *Store) ClearTraceRunActive() error { + if err := removeIfExists(filepath.Join(s.traceDirPath(), traceRunLocalFile)); err != nil { + return err + } + return removeIfExists(filepath.Join(s.traceDirPath(), traceRunActiveFile)) } +// MarkTraceRunLocal marks the running `chainloop trace run` session as local: +// it stores its evidence on disk. ClearTraceRunActive removes the marker. +func (s *Store) MarkTraceRunLocal() error { + base := s.traceDirPath() + if err := os.MkdirAll(base, 0755); err != nil { + return err + } + + return os.WriteFile(filepath.Join(base, traceRunLocalFile), nil, 0600) +} + +// IsTraceRunLocal reports whether the running `chainloop trace run` session +// stores its evidence locally. +func (s *Store) IsTraceRunLocal() bool { + _, err := os.Stat(filepath.Join(s.traceDirPath(), traceRunLocalFile)) + + return err == nil +} + // IsTraceRunActive reports whether a `chainloop trace run` session owns // this store's trace state. func (s *Store) IsTraceRunActive() bool { diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index 853988546..4b683b9fb 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -823,3 +823,19 @@ func TestGCOrphansDropsSpecRedactions(t *testing.T) { assert.FileExists(t, filepath.Join(store.SpecRedactionDir("sess-live"), "copy")) assert.NoDirExists(t, store.SpecRedactionDir("sess-orphan")) } + +// TestTraceRunLocalSentinel covers the marker that tells the agent hooks a +// `trace run` session stores its evidence locally. +func TestTraceRunLocalSentinel(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + assert.False(t, store.IsTraceRunLocal()) + + require.NoError(t, store.MarkTraceRunActive()) + require.NoError(t, store.MarkTraceRunLocal()) + assert.True(t, store.IsTraceRunLocal()) + + require.NoError(t, store.ClearTraceRunActive()) + assert.False(t, store.IsTraceRunActive()) + assert.False(t, store.IsTraceRunLocal(), "clearing the run also clears its local marker") +} diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 228825f1f..98b4d9775 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -206,8 +206,15 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error } if provider.SupportsSessionStartBanner() { + // A `trace run` session takes its mode from the run. Other sessions + // take it from traceStorage in .chainloop.yml. + local := config.IsLocalStorage(repoRoot) + if store.IsTraceRunActive() { + local = store.IsTraceRunLocal() + } + var banner string - if config.IsLocalStorage(repoRoot) { + if local { // Local mode reaches no control plane, so the banner names the // on-disk destination and makes no dashboard round trip (R-012). banner = localSessionStartBanner( diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go index 7ff1da667..8163a9979 100644 --- a/app/cli/pkg/action/trace_agent_hook_test.go +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -130,6 +130,47 @@ func TestHandleAgentSessionStart(t *testing.T) { assert.Contains(t, stdout, "Chainloop Trace is recording this session.") }) + // A `trace run` session takes its mode from the run, not from + // traceStorage in .chainloop.yml. + for _, tc := range []struct { + name string + yml string + runActive bool + runLocal bool + wantLocal bool + }{ + {name: "trace run --local in a control-plane repository says local mode", runActive: true, runLocal: true, wantLocal: true}, + {name: "trace run without --local in a LOCAL repository says control plane", yml: "traceStorage: LOCAL\n", runActive: true}, + {name: "a git-hook session in a LOCAL repository says local mode", yml: "traceStorage: LOCAL\n", wantLocal: true}, + } { + t.Run(tc.name, func(t *testing.T) { + repoDir := initTempGitRepo(t) + store := state.NewGitStore(filepath.Join(repoDir, ".git")) + require.NoError(t, store.InitTraceDir()) + if tc.yml != "" { + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte(tc.yml), 0o600)) + } + if tc.runActive { + require.NoError(t, store.MarkTraceRunActive()) + } + if tc.runLocal { + require.NoError(t, store.MarkTraceRunLocal()) + } + t.Chdir(repoDir) + + withStdin(t, `{"session_id":"mode-test"}`) + stdout := captureStdout(t, func() { + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + }) + + if tc.wantLocal { + assert.Contains(t, stdout, "in local mode") + } else { + assert.NotContains(t, stdout, "local mode") + } + }) + } + t.Run("carries the banner and the spec instruction in one document", func(t *testing.T) { repoDir := initTempGitRepo(t) store := state.NewGitStore(filepath.Join(repoDir, ".git")) diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index ed993c990..48eb4e1db 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -83,9 +83,9 @@ type TraceRunOpts struct { // 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 runs in local mode (--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 } @@ -179,6 +179,14 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error return fmt.Errorf("mark trace run active: %w", err) } + // The agent hooks read the mode of the run from here, not from + // .chainloop.yml. + if opts.Local { + if err := opts.Store.MarkTraceRunLocal(); err != nil { + return fmt.Errorf("mark trace run local: %w", err) + } + } + // The wrapped command is supplied by the user on their own machine // (`chainloop trace run -- `); running it verbatim is the feature and // crosses no trust boundary. From c1e041bcc6bd676794aff55eb976e8f335a78b31 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 17:20:31 +0200 Subject: [PATCH 16/19] feat(trace): index local sessions with an unsigned in-toto statement Replace the custom manifest.json of a locally stored session with statement.json: the in-toto statement a push would sign, with the chainloop.dev/attestation/v0.2 predicate. It is rendered from an in-memory crafting state with the Chainloop renderer, with no envelope and no signature. The disk sink keeps each crafted material as a push keeps it in its crafting state, without inline content, since the bytes are on disk under their digest. The statement subject names the ai-coding-session workflow and the git head commit. Add crafter.GitRepoHead to read the head commit without platform verification. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/trace_hook.go | 2 +- app/cli/internal/trace/README.md | 2 +- app/cli/internal/trace/state/state.go | 4 +- app/cli/internal/trace/state/state_test.go | 4 +- app/cli/pkg/action/trace_hook_handler.go | 7 +- app/cli/pkg/action/trace_local_store.go | 164 +++++++++---------- app/cli/pkg/action/trace_local_store_test.go | 100 ++++++----- app/cli/pkg/action/trace_run.go | 1 + pkg/attestation/crafter/crafter.go | 52 ++++-- pkg/attestation/crafter/crafter_unit_test.go | 36 ++++ 10 files changed, 223 insertions(+), 149 deletions(-) diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go index 2ad36bae2..b1caecf57 100644 --- a/app/cli/cmd/trace_hook.go +++ b/app/cli/cmd/trace_hook.go @@ -141,7 +141,7 @@ func newTraceHookGitPrePushCmd() *cobra.Command { } if mode == config.TraceStorageLocal { - return action.HandlePrePushHookLocal(cmd.Context(), logger) + return action.HandlePrePushHookLocal(cmd.Context(), logger, Version) } // Control plane: build ActionOpts lazily, since this command is diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 0f0118439..8277af1da 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -100,7 +100,7 @@ How the sessions are linked to the commits and attested. |---------|---------------|-------------|--------|--------------|------------| | Commit trailer | Each commit gets a `Chainloop-Trace-Sessions` trailer with the sessions that contributed to it. | Yes | Yes | Yes | Yes | | Attestation at push | 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 as an attestation. It writes the session evidence set to the `local-sessions` folder of the trace directory and sends no attestation. It needs no control plane, login, or network. The stored set is equal to the upload of a push, except for the signature. A `manifest.json` file marks the set as unsigned and not an attestation. `trace init --local` sets up the mode. `trace run --local` stores one wrapped session the same way, with no `.chainloop.yml` setting. Redaction is on by default. Set `CHAINLOOP_TRACE_NO_REDACT` to turn it off, for a trusted local store only (spec issue-3582). | 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 as an attestation. It writes the session evidence set to the `local-sessions` folder of the trace directory and sends no attestation. It needs no control plane, login, or network. The stored set is equal to the upload of a push, except for the signature. A `statement.json` file indexes the set: the in-toto statement that a push would sign, with the `chainloop.dev/attestation/v0.2` predicate. It has no envelope and no signature, so it is not an attestation. `trace init --local` sets up the mode. `trace run --local` stores one wrapped session the same way, with no `.chainloop.yml` setting. Redaction is on by default. Set `CHAINLOOP_TRACE_NO_REDACT` to turn it off, for a trusted local store only (spec issue-3582). | Yes | Yes | Yes | Yes | | Shell commands in another checkout | A session can work in a checkout other than the one it started in through shell commands only, for example in a fresh clone. The checkout is found from the command text: the target of `cd` or `pushd`, the path of `git -C`, and the folder that `git clone` or `gh repo clone` creates. The git hooks are installed there and the session is recorded there, the same as for a file tool edit in that checkout. A clone gets the git hooks only after the command that created it, so a commit in that same command does not carry the session. For OpenCode, a plugin installed by an earlier version does not send the command text: install the hooks again to update it. | Yes | Partial | Yes | Yes | ### Switching storage modes diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go index fcfc78e5b..5c5525e4f 100644 --- a/app/cli/internal/trace/state/state.go +++ b/app/cli/internal/trace/state/state.go @@ -48,8 +48,8 @@ const ( 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 + // storage: one subdirectory per session, each with its materials and an + // unsigned in-toto statement. 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 diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go index 4b683b9fb..cc9a5570f 100644 --- a/app/cli/internal/trace/state/state_test.go +++ b/app/cli/internal/trace/state/state_test.go @@ -121,12 +121,12 @@ func TestRemoveTraceDirPreservesLocalSessions(t *testing.T) { // 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, os.WriteFile(filepath.Join(sessionDir, "statement.json"), []byte("{}"), 0o600)) require.NoError(t, store.RemoveTraceDir()) // The stored sessions survive. - data, err := os.ReadFile(filepath.Join(sessionDir, "manifest.json")) + data, err := os.ReadFile(filepath.Join(sessionDir, "statement.json")) require.NoError(t, err) assert.Equal(t, "{}", string(data)) diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index 30a1831df..fe34ed875 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -546,12 +546,13 @@ func HandlePrePushHook(ctx context.Context, log zerolog.Logger, opts RunTracePus // "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 { +func HandlePrePushHookLocal(ctx context.Context, log zerolog.Logger, cliVersion string) error { drainPushStdin() stored, tracePath, err := RunTraceLocalStore(ctx, log, RunTraceLocalStoreOpts{ - NoRedact: traceNoRedactFromEnv(), - Mode: aicodingsession.ModeCoding, + NoRedact: traceNoRedactFromEnv(), + Mode: aicodingsession.ModeCoding, + CLIVersion: cliVersion, }) if err != nil { return fmt.Errorf("store session evidence locally: %w", err) diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index 4d842d2f5..256d9bc7e 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -17,7 +17,6 @@ package action import ( "context" - "encoding/json" "fmt" "os" "path/filepath" @@ -27,10 +26,15 @@ import ( "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" + attestationapi "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/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/attestation/renderer/chainloop" "github.com/chainloop-dev/chainloop/pkg/casclient" "github.com/rs/zerolog" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" ) // traceNoRedactEnv turns secret redaction off for a local store. It is @@ -47,8 +51,13 @@ func traceNoRedactFromEnv() bool { } const ( - // manifestFileName is the index a reader opens first in a stored session. - manifestFileName = "manifest.json" + // statementFileName is the index of a stored session: an unsigned in-toto + // statement with the Chainloop predicate. It has no envelope and no + // signature, so it is not an attestation (R-006). + statementFileName = "statement.json" + // localWorkflowName names the workflow in the statement subject. Local + // mode has no control-plane workflow, so it uses the trace default name. + localWorkflowName = "ai-coding-session" // 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. @@ -65,6 +74,9 @@ type RunTraceLocalStoreOpts struct { // Mode records how the session was driven, one of the aicodingsession.Mode* // constants. Empty means ModeCoding. Mode string + // CLIVersion is the bare CLI version recorded as the builder in the + // statement. + CLIVersion 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. @@ -103,7 +115,7 @@ func RunTraceLocalStore(ctx context.Context, log zerolog.Logger, opts RunTraceLo return 0, tracePath, nil } - stored, err = storeSessionsLocally(ctx, log, store, sessions, tracePath, opts.NoRedact) + stored, err = storeSessionsLocally(ctx, log, store, sessions, tracePath, opts.NoRedact, repoRoot, opts.CLIVersion) if err != nil { return stored, tracePath, err } @@ -129,11 +141,12 @@ func RunTraceLocalStore(ctx context.Context, log zerolog.Logger, opts RunTraceLo } // storeSessionsLocally writes each assembled session under tracePath in its own -// content-addressed directory: tracePath//manifest.json plus a +// content-addressed directory: tracePath//statement.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) { +// one session. repoRoot is where the head commit of the statement comes from. +// It returns how many sessions were stored. +func storeSessionsLocally(ctx context.Context, log zerolog.Logger, store *state.Store, sessions []sessionEvidence, tracePath string, noRedact bool, repoRoot, cliVersion string) (int, error) { if err := os.MkdirAll(tracePath, 0o700); err != nil { return 0, fmt.Errorf("create trace path: %w", err) } @@ -152,8 +165,8 @@ func storeSessionsLocally(ctx context.Context, log zerolog.Logger, store *state. continue } - if err := sink.writeManifest(!noRedact); err != nil { - return stored, fmt.Errorf("write manifest for session %s: %w", se.sessionID, err) + if err := sink.writeStatement(ctx, repoRoot, cliVersion); err != nil { + return stored, fmt.Errorf("write statement for session %s: %w", se.sessionID, err) } log.Debug().Str("session", se.sessionID).Str("dir", sessionDir). @@ -176,51 +189,23 @@ type diskMaterialSink struct { 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"` + backend *casclient.CASBackend + // materials are the crafted materials, keyed by name, as a push keeps them + // in its crafting state. Their content is on disk, not inline. + materials map[string]*attestationapi.Attestation_Material + // startedAt is when the assembly of the session started, the + // initialization time of the statement. + startedAt time.Time } 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}, + outDir: outDir, + noRedact: noRedact, + logger: logger, + backend: &casclient.CASBackend{Name: "chainloop-trace-local", Uploader: nil}, + materials: make(map[string]*attestationapi.Attestation_Material), + startedAt: time.Now(), } } @@ -256,18 +241,17 @@ func (s *diskMaterialSink) AddMaterial(ctx context.Context, name, path, kind str return "", err } - file, err := s.writeMaterial(artifact.GetDigest(), content) - if err != nil { + if err := s.writeMaterial(artifact.GetDigest(), content); 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), - }) + // The content is on disk under its digest, as a push keeps it in the CAS. + // So the statement references it and does not carry it inline. + material := res.Material + material.InlineCas = false + artifact.Content = nil + material.Annotations = mergeAnnotations(material.GetAnnotations(), annotations) + s.materials[name] = material return artifact.GetDigest(), nil } @@ -292,58 +276,64 @@ func materialContent(inline, override []byte, path string) ([]byte, error) { } } -// 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) { +// writeMaterial stores content under materials/. 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) error { // A ':' in the digest is awkward on some filesystems, so the stored file - // uses '-'; the digest itself is kept intact in the manifest. + // uses '-'; the digest itself is kept intact in the statement. 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) + 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 fmt.Errorf("write material %s: %w", digest, err) } - return rel, nil + return 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() +// writeStatement writes the index of one stored session: the in-toto statement +// a push would sign, rendered from the same kind of crafting state with the +// same Chainloop renderer. It has no envelope and no signature (R-006). The +// head commit comes from repoRoot, with no platform verification, since local +// mode makes no network call. +func (s *diskMaterialSink) writeStatement(ctx context.Context, repoRoot, cliVersion string) error { + head, err := crafter.GitRepoHead(repoRoot, &s.logger) + if err != nil { + s.logger.Debug().Err(err).Msg("could not read the head commit; the statement has none") + } - var evidenceRecord string - for _, m := range s.materials { - if m.Kind == sessionKind { - evidenceRecord = m.Name - break - } + att := &attestationapi.Attestation{ + InitializedAt: timestamppb.New(s.startedAt), + FinishedAt: timestamppb.Now(), + Workflow: &attestationapi.WorkflowMetadata{Name: localWorkflowName}, + Materials: s.materials, + Head: head, + } + + statement, err := chainloop.NewChainloopRendererV02(att, cliVersion, "", nil, &s.logger).Statement(ctx) + if err != nil { + return fmt.Errorf("render statement: %w", err) } - manifest := localManifest{ - GeneratedAt: time.Now().UTC().Format(time.RFC3339), - Signed: false, - IsAttestation: false, - Redaction: redaction, - EvidenceRecord: evidenceRecord, - Materials: s.materials, + if err := statement.Validate(); err != nil { + return fmt.Errorf("validate statement: %w", err) } - data, err := json.MarshalIndent(manifest, "", " ") + data, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(statement) if err != nil { - return fmt.Errorf("encode manifest: %w", err) + return fmt.Errorf("encode statement: %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) + return os.WriteFile(filepath.Join(s.outDir, statementFileName), data, 0o600) } // mergeAnnotations combines the annotations the crafter set on a material with diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index 7db180b44..1ce74c0e0 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -18,6 +18,7 @@ package action import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "os" @@ -29,14 +30,18 @@ import ( "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/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" + intoto "github.com/in-toto/attestation/go/v1" "github.com/rs/zerolog" + "github.com/secure-systems-lab/go-securesystemslib/dsse" "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. +// push runs is written to disk, content-addressed, redacted by default, with an +// unsigned in-toto statement as its index. The statement has no envelope and no +// signature, so it is not an attestation. func TestStoreSessionsLocally(t *testing.T) { const sessionID = "7c1e2d-session" // Assembled from fragments so nothing credential-shaped is committed. @@ -72,45 +77,61 @@ func TestStoreSessionsLocally(t *testing.T) { 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) + stored, err := storeSessionsLocally(ctx, zerolog.Nop(), state.NewGitStore(t.TempDir()), []sessionEvidence{newSession()}, tracePath, false, t.TempDir(), "test") 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) + statement, mats := readLocalStatement(t, sessionDir) + + // R-006: the index is a Chainloop in-toto statement, with no envelope. + assert.Equal(t, chainloop.PredicateTypeV02, statement.GetPredicateType()) + assert.Equal(t, "chainloop.workflow."+localWorkflowName, statement.GetSubject()[0].GetName()) + entries, err := os.ReadDir(sessionDir) + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + assert.ElementsMatch(t, []string{statementFileName, materialsDirName}, names, "no envelope, no bundle") + require.NotEmpty(t, mats) + + var specDigest, recordFile string + for _, mat := range mats { + // The content is on disk, not inline in the statement. + assert.False(t, mat.EmbeddedInline, "material %s is not inline", mat.Name) + // The content is on local disk, not in a CAS a reader could + // download it from. + assert.False(t, mat.UploadedToCAS, "material %s is not in a CAS", mat.Name) + + digest := mat.Hash.String() + file := filepath.Join(sessionDir, materialsDirName, strings.ReplaceAll(digest, ":", "-")) + content, err := os.ReadFile(file) + require.NoError(t, err, "material %s is stored under its digest", mat.Name) // 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) + assert.Equal(t, "sha256:"+hex.EncodeToString(sum[:]), 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 + switch mat.Type { + case "EVIDENCE": + specDigest = 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) + assert.Equal(t, pushAdder.byName(mat.Name).digest, digest, "same digest as a push for %s", mat.Name) + case "CHAINLOOP_AI_CODING_SESSION": + assert.Equal(t, "ai-coding-session-7c1e2d", mat.Name) + recordFile = file } } require.NotEmpty(t, specDigest, "the ticket became an EVIDENCE material") + require.NotEmpty(t, recordFile, "the session evidence record is a 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) + readJSONFile(t, recordFile, &ev) referenced := make([]string, 0, len(ev.Data.Spec)) for _, e := range ev.Data.Spec { referenced = append(referenced, e.Digest) @@ -120,18 +141,16 @@ func TestStoreSessionsLocally(t *testing.T) { 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) + stored, err := storeSessionsLocally(ctx, zerolog.Nop(), state.NewGitStore(t.TempDir()), []sessionEvidence{newSession()}, tracePath, true, t.TempDir(), "test") 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") + _, mats := readLocalStatement(t, sessionDir) var found bool - for _, mat := range m.Materials { - content, err := os.ReadFile(filepath.Join(sessionDir, mat.File)) + for _, mat := range mats { + content, err := os.ReadFile(filepath.Join(sessionDir, materialsDirName, strings.ReplaceAll(mat.Hash.String(), ":", "-"))) require.NoError(t, err) if strings.Contains(string(content), pat) { found = true @@ -193,15 +212,22 @@ func readJSONFile(t *testing.T, path string, v any) { require.NoError(t, json.Unmarshal(data, v)) } -func materialByName(t *testing.T, m localManifest, name string) storedMaterial { +// readLocalStatement reads the in-toto statement of a stored session and the +// materials of its Chainloop predicate. +func readLocalStatement(t *testing.T, sessionDir string) (*intoto.Statement, []*chainloop.NormalizedMaterial) { 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{} + raw, err := os.ReadFile(filepath.Join(sessionDir, statementFileName)) + require.NoError(t, err) + + // The renderer reads predicates from an envelope. An unsigned one carries + // the statement as is. + envelope := &dsse.Envelope{PayloadType: "application/vnd.in-toto+json", Payload: base64.StdEncoding.EncodeToString(raw)} + statement, err := chainloop.ExtractStatement(envelope) + require.NoError(t, err) + predicate, err := chainloop.ExtractPredicate(envelope) + require.NoError(t, err) + + return statement, predicate.GetMaterials() } // TestRunTraceLocalStorePartialFailure covers a session that cannot be stored. diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go index 48eb4e1db..0ff219b73 100644 --- a/app/cli/pkg/action/trace_run.go +++ b/app/cli/pkg/action/trace_run.go @@ -211,6 +211,7 @@ func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error NoRedact: traceNoRedactFromEnv(), Mode: aicodingsession.ModeGeneric, AllowEmpty: true, + CLIVersion: opts.CLIVersion, }) switch { case storeErr != nil: diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 03f997054..c2a6345b5 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -326,6 +326,41 @@ type CommitRemote struct { Name, URL string } +// toProto maps the head commit to the attestation commit. +func (h *HeadCommit) toProto() *api.Commit { + commit := &api.Commit{ + Hash: h.Hash, + AuthorEmail: h.AuthorEmail, + AuthorName: h.AuthorName, + Date: timestamppb.New(h.Date), + Message: h.Message, + Signature: h.Signature, + PlatformVerification: h.PlatformVerification, + } + + for _, r := range h.Remotes { + commit.Remotes = append(commit.Remotes, &api.Commit_Remote{ + Name: r.Name, + Url: r.URL, + }) + } + + return commit +} + +// GitRepoHead returns the head commit of the git repository at path, as the +// attestation records it. It returns nil when path is not in a repository or +// the repository has no commit. It makes no network call, so it does no +// platform verification of the commit. +func GitRepoHead(path string, logger *zerolog.Logger) (*api.Commit, error) { + head, err := gracefulGitRepoHead(path, logger) + if err != nil || head == nil { + return nil, err + } + + return head.toProto(), nil +} + // Returns the current directory git commit hash if possible // If we are not in a git repo it will return an empty string func gracefulGitRepoHead(path string, logger *zerolog.Logger) (*HeadCommit, error) { @@ -464,22 +499,7 @@ func initialCraftingState(cwd string, opts *InitOpts) (*api.CraftingState, error headCommit.PlatformVerification = verifyCommitWithPlatform(headCommit, opts.Runner, opts.Logger) } - headCommitP = &api.Commit{ - Hash: headCommit.Hash, - AuthorEmail: headCommit.AuthorEmail, - AuthorName: headCommit.AuthorName, - Date: timestamppb.New(headCommit.Date), - Message: headCommit.Message, - Signature: headCommit.Signature, - PlatformVerification: headCommit.PlatformVerification, - } - - for _, r := range headCommit.Remotes { - headCommitP.Remotes = append(headCommitP.Remotes, &api.Commit_Remote{ - Name: r.Name, - Url: r.URL, - }) - } + headCommitP = headCommit.toProto() } var tsURL, caName string diff --git a/pkg/attestation/crafter/crafter_unit_test.go b/pkg/attestation/crafter/crafter_unit_test.go index 856df7c87..e518a6176 100644 --- a/pkg/attestation/crafter/crafter_unit_test.go +++ b/pkg/attestation/crafter/crafter_unit_test.go @@ -391,3 +391,39 @@ func (s *crafterUnitSuite) TestGitRepoHeadWorktree() { func TestSuite(t *testing.T) { suite.Run(t, new(crafterUnitSuite)) } + +func (s *crafterUnitSuite) TestGitRepoHeadProto() { + s.Run("not a repository", func() { + head, err := GitRepoHead(s.T().TempDir(), nil) + s.NoError(err) + s.Nil(head) + }) + + s.Run("a repository with a commit", func() { + repoPath := s.T().TempDir() + repo, err := git.PlainInit(repoPath, false) + s.Require().NoError(err) + s.Require().NoError(disableGPGSign(repo)) + _, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{"git@cyberdyne.com:skynet.git"}}) + s.Require().NoError(err) + + wt, err := repo.Worktree() + s.Require().NoError(err) + s.Require().NoError(os.WriteFile(filepath.Join(repoPath, "f"), []byte("x"), 0600)) + _, err = wt.Add("f") + s.Require().NoError(err) + h, err := wt.Commit("test commit", &git.CommitOptions{ + Author: &object.Signature{Name: "John Doe", Email: "john@doe.org", When: time.Now()}, + }) + s.Require().NoError(err) + + head, err := GitRepoHead(repoPath, nil) + s.Require().NoError(err) + s.Require().NotNil(head) + s.Equal(h.String(), head.GetHash()) + s.Equal("john@doe.org", head.GetAuthorEmail()) + s.Equal("test commit", head.GetMessage()) + s.Require().Len(head.GetRemotes(), 1) + s.Equal("origin", head.GetRemotes()[0].GetName()) + }) +} From 5cba71319d02bbebb0bc7bbf048d9d28d081d5f1 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 17:35:55 +0200 Subject: [PATCH 17/19] docs(spec): record the implementation decisions of trace local mode Add Decision Record rows D-021 to D-024 to spec issue-3582: the unsigned in-toto statement as the session index, tracePath as the trace directory in every mode, the --local flag of trace run, and no trace init for a clone of a local-mode repository. Refs #3582 Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- docs/specs/issue-3582-trace-evidence-export.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/specs/issue-3582-trace-evidence-export.md b/docs/specs/issue-3582-trace-evidence-export.md index 9a20fbaf1..5e8c859ca 100644 --- a/docs/specs/issue-3582-trace-evidence-export.md +++ b/docs/specs/issue-3582-trace-evidence-export.md @@ -138,6 +138,10 @@ flowchart TD | D-018 | How local mode is configured | `.chainloop.yml` carries `traceStorage` (CONTROL_PLANE default, or LOCAL), an optional `tracePath`, and `traceRequired`. The old `requireTrace` and `localTrace` are deprecated | A committed storage mode is an explicit, visible repository policy, which is what a local-trace repository wants. This supersedes D-013, whose per-clone marker avoided an accidental shared flag. An explicit `traceStorage` is intentional, not accidental, so a shared field is right. The old boolean fields are replaced, and a later change drops them. Rejected: a per-clone-only local flag (a local-trace repository is a team choice, not a per-developer one). | owner request | | D-019 | Where local sessions are stored | Under `tracePath`, content-addressed with a manifest, with a default path when `tracePath` is absent | A reader finds each material by its digest, and the layout matches the content-addressed store the backend uses. Rejected: a path with no default (every local repository would have to set one). | owner request | | D-020 | Exporting an already-recorded session | Out of scope. A future spec | The value here is recording without a control plane. A command that exports an existing session on demand is a separate feature with its own surface. This reverses D-015. Rejected: folding it into this spec (it widens the scope and confuses the use case). | PR review | +| D-021 | The index of a stored session | An unsigned in-toto statement, `statement.json`, with the Chainloop predicate (`chainloop.dev/attestation/v0.2`). It has no envelope and no signature | The statement is the index a push signs, and it lists each material by name, type, and digest. The same renderer makes it, so readers and policies that read a Chainloop statement also read a stored session. A later change can sign a stored statement and push it to the control plane. The statement has no envelope, so nothing can verify it as an attestation. This refines R-006 and D-003: the index states that it is unsigned by its form, not with a field. Rejected: a custom `manifest.json` (a second format that only Trace reads). | implementation | +| D-022 | What `tracePath` sets | The trace directory, where Trace keeps its data, in every storage mode. The default is `.git/chainloop-trace`. A relative path starts at the root of the checkout. Local mode writes the sessions to the `local-sessions` folder of that directory. `--trace-path` does not turn on local mode | A path is not a storage mode, and a later change can add more modes. One directory for all the state of a checkout keeps the git hooks, the agent hooks, and the push in agreement. Each linked worktree keeps its own state, as with the default. This refines D-019. Rejected: a `tracePath` that only names the local sessions folder and turns on local mode ([review](https://github.com/chainloop-dev/chainloop/pull/3617#issuecomment-6179394948)). | PR review | +| D-023 | How `trace run` selects local mode | A `--local` flag. `trace run` does not read `traceStorage`. With `--local`, the control-plane flags are an error. `trace run` still uses `tracePath`, because the agent hooks keep the session in that directory | `trace run` takes its settings from its flags, so a single run in any directory can store its session locally. This refines D-017. Rejected: reading `traceStorage` from `.chainloop.yml` (a single run then depends on a repository policy). | implementation | +| D-024 | Setup of a clone of a local-mode repository | No `trace init`. The first traced session in the clone installs the git hooks, as in control-plane mode | `trace init` is a one-time setup for each repository. A teammate who clones gets the policy from `.chainloop.yml` and the harness hooks from the repository. Rejected: each teammate runs `trace init --local` after cloning ([review](https://github.com/chainloop-dev/chainloop/pull/3617#issuecomment-6179430560)). | PR review | ## Open Questions - [ ] **Encrypt the stored sessions at rest?** Proposed: no by default. The stored session holds what a working copy of the session already holds, and redaction drops secrets by default. An opt-in encryption could come later. Left for reviewers. From ef6f63f73b633f793b2c7d521b1ebc69e2291dcb Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 18:02:02 +0200 Subject: [PATCH 18/19] test(trace): use constants for repeated flag fixtures Fix the goconst findings of the lint jobs in the trace init and trace run flag tests. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/cmd/trace_init_local_test.go | 4 ++-- app/cli/cmd/trace_run_local_test.go | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/cli/cmd/trace_init_local_test.go b/app/cli/cmd/trace_init_local_test.go index 72981fd2f..3858bc0c5 100644 --- a/app/cli/cmd/trace_init_local_test.go +++ b/app/cli/cmd/trace_init_local_test.go @@ -124,8 +124,8 @@ func TestTraceInitLocal(t *testing.T) { 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 + {flagWorkflowArg, "my-workflow"}, + {"--project", fixtureProject}, } { repoDir := t.TempDir() gitInit(t, repoDir) diff --git a/app/cli/cmd/trace_run_local_test.go b/app/cli/cmd/trace_run_local_test.go index 3ff2e6f5d..bbabde666 100644 --- a/app/cli/cmd/trace_run_local_test.go +++ b/app/cli/cmd/trace_run_local_test.go @@ -24,6 +24,13 @@ import ( "github.com/stretchr/testify/require" ) +// Fixture values for the flag tests of trace init and trace run. +const ( + flagWorkflowArg = "--workflow" + fixtureProject = "my-project" + fixtureVersion = "v1.0.0" +) + // runTraceRun executes `trace run` with the given args in dir. It returns the // RunE error. func runTraceRun(t *testing.T, dir string, args ...string) error { @@ -43,9 +50,9 @@ func TestTraceRunLocalFlag(t *testing.T) { t.Run("control-plane flags are rejected with --local", func(t *testing.T) { for _, flag := range [][]string{ {"--org", "acme"}, - {"--project", "my-project"}, - {"--workflow", "my-workflow"}, - {"--version", "v1.0.0"}, + {"--project", fixtureProject}, + {flagWorkflowArg, "my-workflow"}, + {"--version", fixtureVersion}, {"--contract", "my-contract"}, } { repoDir := t.TempDir() From 256757f410e7da1dd9c41adec9dd5afbecc035d0 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 15 Oct 2026 19:04:11 +0200 Subject: [PATCH 19/19] refactor(trace): rename the materials folder of a stored session to evidence A locally stored session now keeps its content-addressed files under evidence/ next to statement.json. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: ac261808-7f67-40a0-b901-d1d43b9dd863 --- app/cli/pkg/action/trace_local_store.go | 10 +++++----- app/cli/pkg/action/trace_local_store_test.go | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/cli/pkg/action/trace_local_store.go b/app/cli/pkg/action/trace_local_store.go index 256d9bc7e..caad73845 100644 --- a/app/cli/pkg/action/trace_local_store.go +++ b/app/cli/pkg/action/trace_local_store.go @@ -58,10 +58,10 @@ const ( // localWorkflowName names the workflow in the statement subject. Local // mode has no control-plane workflow, so it uses the trace default name. localWorkflowName = "ai-coding-session" - // materialsDirName holds every material of a session, each file named by its + // evidenceDirName 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" + evidenceDirName = "evidence" ) // RunTraceLocalStoreOpts configures a disk store of the recorded session @@ -142,7 +142,7 @@ func RunTraceLocalStore(ctx context.Context, log zerolog.Logger, opts RunTraceLo // storeSessionsLocally writes each assembled session under tracePath in its own // content-addressed directory: tracePath//statement.json plus a -// materials/ folder whose files are named by digest. Each session is +// evidence/ 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. repoRoot is where the head commit of the statement comes from. // It returns how many sessions were stored. @@ -276,13 +276,13 @@ func materialContent(inline, override []byte, path string) ([]byte, error) { } } -// writeMaterial stores content under materials/. Materials are +// writeMaterial stores content under evidence/. 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) error { // A ':' in the digest is awkward on some filesystems, so the stored file // uses '-'; the digest itself is kept intact in the statement. - rel := filepath.Join(materialsDirName, strings.ReplaceAll(digest, ":", "-")) + rel := filepath.Join(evidenceDirName, strings.ReplaceAll(digest, ":", "-")) abs := filepath.Join(s.outDir, rel) if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil { diff --git a/app/cli/pkg/action/trace_local_store_test.go b/app/cli/pkg/action/trace_local_store_test.go index 1ce74c0e0..8e8d8e881 100644 --- a/app/cli/pkg/action/trace_local_store_test.go +++ b/app/cli/pkg/action/trace_local_store_test.go @@ -93,7 +93,7 @@ func TestStoreSessionsLocally(t *testing.T) { for _, e := range entries { names = append(names, e.Name()) } - assert.ElementsMatch(t, []string{statementFileName, materialsDirName}, names, "no envelope, no bundle") + assert.ElementsMatch(t, []string{statementFileName, evidenceDirName}, names, "no envelope, no bundle") require.NotEmpty(t, mats) var specDigest, recordFile string @@ -105,7 +105,7 @@ func TestStoreSessionsLocally(t *testing.T) { assert.False(t, mat.UploadedToCAS, "material %s is not in a CAS", mat.Name) digest := mat.Hash.String() - file := filepath.Join(sessionDir, materialsDirName, strings.ReplaceAll(digest, ":", "-")) + file := filepath.Join(sessionDir, evidenceDirName, strings.ReplaceAll(digest, ":", "-")) content, err := os.ReadFile(file) require.NoError(t, err, "material %s is stored under its digest", mat.Name) @@ -150,7 +150,7 @@ func TestStoreSessionsLocally(t *testing.T) { var found bool for _, mat := range mats { - content, err := os.ReadFile(filepath.Join(sessionDir, materialsDirName, strings.ReplaceAll(mat.Hash.String(), ":", "-"))) + content, err := os.ReadFile(filepath.Join(sessionDir, evidenceDirName, strings.ReplaceAll(mat.Hash.String(), ":", "-"))) require.NoError(t, err) if strings.Contains(string(content), pat) { found = true