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