diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index ace1da2a8..55e3ce8c2 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -93,6 +93,12 @@ func Execute(rootCmd *cobra.Command) error { } 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 + // with SilenceErrors set main is the only place those errors get printed. + // A zero-value zerolog.Logger has no writer and would drop them silently. + logger = l + rootCmd := &cobra.Command{ Use: appName, Short: "Chainloop Command Line Interface", @@ -290,6 +296,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { newAttestationCmd(), newArtifactCmd(), newConfigCmd(), newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(), newReferrerDiscoverCmd(), newPolicyCmd(), newApplyCmd(), + newTraceCmd(), ) // Load plugins for root command and subcommands (except completion and help) @@ -329,6 +336,13 @@ func isTelemetryDisabled() bool { return os.Getenv(doNotTrackEnv) == "1" || os.Getenv(doNotTrackEnv) == trueString || Version == devVersion } +// Logger returns the root logger. Hook commands swap it in place (see +// InitHookLogger), and main reads it for its final error line so that line +// shares the hook's colorless formatting during a git commit or push. +func Logger() zerolog.Logger { + return logger +} + func initLogger(logger zerolog.Logger) (zerolog.Logger, error) { lvl := zerolog.InfoLevel if flagDebug { diff --git a/app/cli/cmd/root_test.go b/app/cli/cmd/root_test.go index 8799c7fa1..65ef8303b 100644 --- a/app/cli/cmd/root_test.go +++ b/app/cli/cmd/root_test.go @@ -15,8 +15,11 @@ package cmd import ( + "bytes" "testing" + "github.com/rs/zerolog" + "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/stretchr/testify/assert" @@ -111,3 +114,18 @@ func TestLoadAuthToken(t *testing.T) { }) } } + +// Cobra rejects unknown subcommands and invalid flags before PersistentPreRunE +// runs, so main prints those errors through a Logger() that PersistentPreRunE +// never got to initialize. NewRootCmd must leave the root logger usable. +func TestNewRootCmdInitializesLogger(t *testing.T) { + previous := logger + t.Cleanup(func() { logger = previous }) + + var out bytes.Buffer + NewRootCmd(zerolog.New(&out)) + + rootLogger := Logger() + rootLogger.Error().Msg("boom") + assert.Contains(t, out.String(), "boom") +} diff --git a/app/cli/cmd/trace.go b/app/cli/cmd/trace.go new file mode 100644 index 000000000..d4041814d --- /dev/null +++ b/app/cli/cmd/trace.go @@ -0,0 +1,37 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +// newTraceCmd creates the trace parent command with all subcommands. +func newTraceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "trace", + Short: "AI coding session tracing and attestation", + } + + cmd.AddCommand( + newTraceInitCmd(), + newTraceRunCmd(), + newTraceUninstallCmd(), + newTraceHookCmd(), + ) + + return cmd +} diff --git a/app/cli/cmd/trace_hook.go b/app/cli/cmd/trace_hook.go new file mode 100644 index 000000000..974465fd0 --- /dev/null +++ b/app/cli/cmd/trace_hook.go @@ -0,0 +1,316 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/spf13/cobra" +) + +func newTraceHookCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "hook", + Short: "Internal: handle hook invocations", + Hidden: true, + } + + cmd.AddCommand( + newTraceHookGitCmd(), + newTraceHookClaudeCmd(), + newTraceHookCursorCmd(), + newTraceHookOpenCodeCmd(), + ) + + return cmd +} + +func newTraceHookGitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "git", + Short: "Handle git hook invocations", + } + + cmd.AddCommand( + newTraceHookGitCommitMsgCmd(), + newTraceHookGitPostCommitCmd(), + newTraceHookGitPrePushCmd(), + ) + + return cmd +} + +func newTraceHookGitCommitMsgCmd() *cobra.Command { + return &cobra.Command{ + Use: "commit-msg [msg-file]", + Short: "Handle the commit-msg git hook", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + cleanup := InitHookLogger() + defer cleanup() + + return action.HandleCommitMsgHook(cmd.Context(), args[0], logger) + }, + } +} + +func newTraceHookGitPostCommitCmd() *cobra.Command { + return &cobra.Command{ + Use: "post-commit", + Short: "Handle the post-commit git hook", + RunE: func(cmd *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandlePostCommitHook(cmd.Context(), logger) + }, + } +} + +func newTraceHookGitPrePushCmd() *cobra.Command { + return &cobra.Command{ + Use: "pre-push", + Short: "Handle the pre-push git hook", + RunE: func(cmd *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + + requireTrace := config.LoadRequireTraceFromYML(".") + + return action.HandlePrePushHook(cmd.Context(), requireTrace, logger, action.RunTracePushOpts{ + ActionOpts: ActionOpts, + CLIVersion: Version, + }) + }, + } +} + +func newTraceHookClaudeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "claude", + Short: "Handle Claude Code hook invocations", + } + + cmd.AddCommand( + newTraceHookClaudeSessionStartCmd(), + newTraceHookClaudeSessionEndCmd(), + newTraceHookClaudePreToolUseCmd(), + newTraceHookClaudePostToolUseCmd(), + ) + + return cmd +} + +func newTraceHookClaudeSessionStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-start", + Short: "Capture session ID on Claude Code session start", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionStart(claude.New(), logger) + }, + } +} + +func newTraceHookClaudeSessionEndCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-end", + Short: "Clean up trace state on Claude Code session end", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionEnd(claude.New(), logger) + }, + } +} + +func newTraceHookClaudePreToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "pre-tool-use", + Short: "Snapshot file before Claude Code edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPreToolUse(claude.New(), logger) + }, + } +} + +func newTraceHookClaudePostToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "post-tool-use", + Short: "Record AI line ranges after Claude Code edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPostToolUse(claude.New(), logger) + }, + } +} + +func newTraceHookCursorCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cursor", + Short: "Handle Cursor hook invocations", + } + + cmd.AddCommand( + newTraceHookCursorSessionStartCmd(), + newTraceHookCursorSessionEndCmd(), + newTraceHookCursorAfterFileEditCmd(), + ) + + return cmd +} + +func newTraceHookCursorSessionStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-start", + Short: "Capture session ID on Cursor session start", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionStart(cursor.New(), logger) + }, + } +} + +func newTraceHookCursorSessionEndCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-end", + Short: "Clean up trace state on Cursor session end", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionEnd(cursor.New(), logger) + }, + } +} + +func newTraceHookCursorAfterFileEditCmd() *cobra.Command { + return &cobra.Command{ + Use: "after-file-edit", + Short: "Record AI line ranges after Cursor file edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPostToolUse(cursor.New(), logger) + }, + } +} + +func newTraceHookOpenCodeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "opencode", + Short: "Handle opencode hook invocations", + } + + cmd.AddCommand( + newTraceHookOpenCodeSessionStartCmd(), + newTraceHookOpenCodeSessionEndCmd(), + newTraceHookOpenCodePreToolUseCmd(), + newTraceHookOpenCodePostToolUseCmd(), + ) + + return cmd +} + +func newTraceHookOpenCodeSessionStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-start", + Short: "Capture session ID on opencode session start", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionStart(opencode.New(), logger) + }, + } +} + +func newTraceHookOpenCodeSessionEndCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-end", + Short: "Clean up trace state on opencode session end", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentSessionEnd(opencode.New(), logger) + }, + } +} + +func newTraceHookOpenCodePreToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "pre-tool-use", + Short: "Snapshot file before opencode edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPreToolUse(opencode.New(), logger) + }, + } +} + +func newTraceHookOpenCodePostToolUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "post-tool-use", + Short: "Record AI line ranges after opencode edit", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + cleanup := InitHookLogger() + defer cleanup() + return action.HandleAgentPostToolUse(opencode.New(), logger) + }, + } +} diff --git a/app/cli/cmd/trace_hooklog.go b/app/cli/cmd/trace_hooklog.go new file mode 100644 index 000000000..22fed008a --- /dev/null +++ b/app/cli/cmd/trace_hooklog.go @@ -0,0 +1,136 @@ +// +// 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 ( + "io" + "os" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" +) + +// hookLogFile holds the trace log file while hook commands run, so it can be +// closed by the cleanup closure returned from InitHookLogger. +var hookLogFile *os.File + +// stderrMinLevel is the minimum zerolog.Level that hook commands write to +// stderr. All levels still reach the on-disk trace log; stderr is filtered so +// a hook stays readable when git interleaves its output with the commit or +// push it is running inside. InitHookLogger sets it per invocation. +var stderrMinLevel = zerolog.InfoLevel + +// levelFilterWriter only forwards log entries at or above minLevel to w. +// Used by hook commands to keep stderr quiet while letting the trace log +// file receive every level. +type levelFilterWriter struct { + w io.Writer + minLevel zerolog.Level +} + +func (lw *levelFilterWriter) Write(p []byte) (n int, err error) { + return len(p), nil +} + +func (lw *levelFilterWriter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) { + if level < lw.minLevel { + return len(p), nil + } + + return lw.w.Write(p) +} + +// hookStderrWriter builds the colorless stderr writer used by hook commands. +// The level filter keeps stderr quiet during normal operations while still +// letting the trace log file capture every level. +func hookStderrWriter() *levelFilterWriter { + return &levelFilterWriter{ + w: zerolog.ConsoleWriter{ + Out: os.Stderr, + FormatTimestamp: func(any) string { return "" }, + NoColor: true, + }, + minLevel: stderrMinLevel, + } +} + +// InitHookLogger reconfigures the root logger for git/agent hook commands: +// colorless output, all levels written to the trace state's log.txt, and +// Warn+ (or Debug, with --debug) written to stderr. Colorless matters because +// the hook's output is interleaved with git's during a commit or push, and +// main reads the same logger for its final error line. +// +// If trace state cannot be located or the file cannot be opened, the logger +// falls back to colorless stderr-only output so the rest of the hook still +// runs with consistent formatting. Returns a cleanup function that closes the +// log file — callers should defer it at the top of a hook's RunE. +func InitHookLogger() func() { + closeHookLogFile() + + // Mirror initLogger: Info normally, Debug with --debug. Info matters — + // it carries the "commit record saved" / attestation feedback that tells + // the user trace is doing something during a commit or push. The log file + // always gets every level regardless. + stderrMinLevel = zerolog.InfoLevel + if flagDebug { + stderrMinLevel = zerolog.DebugLevel + } + + logger = zerolog.New(hookStderrWriter()).Level(stderrMinLevel) + + store, _, err := state.Locate() + if err != nil { + return func() {} + } + + if err := initHookLogFile(store); err != nil { + logger.Debug().Err(err).Msg("could not open hook log file") + return func() {} + } + + return closeHookLogFile +} + +// initHookLogFile opens the trace log file and reassigns the root logger to a +// MultiLevelWriter that writes all levels to the file and delegates stderr +// output to a level-filtered colorless console writer. +func initHookLogFile(store *state.Store) error { + logPath := store.LogFilePath() + + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + hookLogFile = f + + fileWriter := zerolog.ConsoleWriter{ + Out: f, + NoColor: true, + } + + logger = zerolog.New(zerolog.MultiLevelWriter(fileWriter, hookStderrWriter())). + Level(zerolog.DebugLevel). + With().Timestamp().Logger() + + return nil +} + +func closeHookLogFile() { + if hookLogFile != nil { + _ = hookLogFile.Close() + hookLogFile = nil + } +} diff --git a/app/cli/cmd/trace_hooklog_test.go b/app/cli/cmd/trace_hooklog_test.go new file mode 100644 index 000000000..1b8fce80d --- /dev/null +++ b/app/cli/cmd/trace_hooklog_test.go @@ -0,0 +1,104 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetHookLoggerState restores the package-level logger globals to their +// defaults so tests don't leak state to each other. +func resetHookLoggerState(t *testing.T) { + t.Helper() + t.Cleanup(func() { + closeHookLogFile() + stderrMinLevel = zerolog.WarnLevel + }) +} + +func TestInitHookLogFile(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + resetHookLoggerState(t) + + require.NoError(t, initHookLogFile(store)) + defer closeHookLogFile() + + // Write a debug message — should appear in the log file + logger.Debug().Msg("test debug message") + + // Close to flush + closeHookLogFile() + + logPath := store.LogFilePath() + content, err := os.ReadFile(logPath) + require.NoError(t, err) + assert.Contains(t, string(content), "test debug message") +} + +func TestInitHookLogFileCreatesFile(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + resetHookLoggerState(t) + + logPath := store.LogFilePath() + _, err := os.Stat(logPath) + assert.True(t, os.IsNotExist(err)) + + require.NoError(t, initHookLogFile(store)) + closeHookLogFile() + + _, err = os.Stat(logPath) + assert.NoError(t, err) +} + +func TestInitHookLogFileAppendsToExisting(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + resetHookLoggerState(t) + + logPath := store.LogFilePath() + require.NoError(t, os.WriteFile(logPath, []byte("existing content\n"), 0600)) + + require.NoError(t, initHookLogFile(store)) + logger.Debug().Msg("appended message") + closeHookLogFile() + + content, err := os.ReadFile(logPath) + require.NoError(t, err) + assert.Contains(t, string(content), "existing content") + assert.Contains(t, string(content), "appended message") +} + +func TestInitHookLogFileFailsOnBadPath(t *testing.T) { + err := initHookLogFile(state.NewGitStore(filepath.Join(t.TempDir(), "nonexistent", "deep"))) + assert.Error(t, err) +} + +func TestCloseHookLogFileIdempotent(_ *testing.T) { + // Calling close without init should not panic + closeHookLogFile() + closeHookLogFile() +} diff --git a/app/cli/cmd/trace_init.go b/app/cli/cmd/trace_init.go new file mode 100644 index 000000000..a012b349c --- /dev/null +++ b/app/cli/cmd/trace_init.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 cmd + +import ( + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/spf13/cobra" +) + +// newTraceInitCmd creates the trace init subcommand. +func newTraceInitCmd() *cobra.Command { + var ( + project string + claudeFlag bool + cursorFlag bool + opencodeFlag bool + ) + + cmd := &cobra.Command{ + Use: "init", + 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. + +This installs the managed git hooks plus agent-specific hooks for the +selected providers. Pass --claude, --cursor, and/or --opencode to pick +providers; when none is set, Claude Code is used as the default. + +Pass --org to pin the Chainloop organization trace attestations target; the +value is saved to .chainloop.yml and overrides the CLI default on every push. + +Pass --workflow to override the workflow name used when initializing trace +attestations. The value is saved to .chainloop.yml and defaults to +"ai-coding-session" when unset.`, + RunE: func(cmd *cobra.Command, _ []string) error { + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + if err != nil { + return err + } + + // If --project was given, persist it to .chainloop.yml + if project != "" { + if err := config.SaveProjectToYML(repoRoot, project); err != nil { + return err + } + logger.Info().Str("project", project).Msg("project name saved to .chainloop.yml") + } else { + project = config.LoadProjectFromYML(repoRoot) + } + if project == "" { + return fmt.Errorf("--project is required (or add projectName to .chainloop.yml)") + } + + // When the user explicitly passes the inherited persistent --org flag, + // persist it to .chainloop.yml so trace hooks target it on every push. + if cmd.Flags().Changed("org") { + orgFlag, err := cmd.Flags().GetString("org") + if err != nil { + return fmt.Errorf("reading --org flag: %w", err) + } + if err := config.SaveOrganizationToYML(repoRoot, orgFlag); err != nil { + return err + } + logger.Info().Str("organization", orgFlag).Msg("organization saved to .chainloop.yml") + } + + if cmd.Flags().Changed("workflow") { + workflowFlag, err := cmd.Flags().GetString("workflow") + if err != nil { + return fmt.Errorf("reading --workflow flag: %w", err) + } + if err := config.SaveWorkflowToYML(repoRoot, workflowFlag); err != nil { + return err + } + logger.Info().Str("workflow", workflowFlag).Msg("workflow saved to .chainloop.yml") + } + workflowName := config.ResolveWorkflowName(config.LoadWorkflowFromYML(repoRoot)) + + // Persist --require-trace when explicitly set, otherwise + // load the persisted value from .chainloop.yml. + requireTrace := config.LoadRequireTraceFromYML(repoRoot) + if cmd.Flags().Changed("require-trace") { + val, err := cmd.Flags().GetBool("require-trace") + if err != nil { + return fmt.Errorf("reading --require-trace flag: %w", err) + } + requireTrace = val + if err := config.SaveRequireTraceToYML(repoRoot, requireTrace); err != nil { + return err + } + } + + // Verify authentication. If the repo pins an organization, use it so + // the auth check reflects the org the hooks will actually target. + var authExecOpts []action.ExecutorOption + if forcedOrg := config.LoadOrganizationFromYML(repoRoot); forcedOrg != "" { + authExecOpts = append(authExecOpts, action.WithForcedOrganization(forcedOrg)) + } + executor, err := action.NewAttestationExecutor(ActionOpts, Version, authExecOpts...) + if err != nil { + return err + } + defer func() { _ = executor.Close() }() + + if err := executor.CheckAuth(cmd.Context()); err != nil { + logger.Warn().Err(err).Msg("authentication check failed") + if requireTrace { + logger.Warn().Msg("require-trace is enabled: pushes with AI-assisted commits will fail until authenticated") + } else { + logger.Warn().Msg("hooks will be installed, but attestation commands will fail until authenticated") + } + } + + // Create trace directory structure + store := state.NewGitStore(gitDir) + if err := store.InitTraceDir(); err != nil { + return fmt.Errorf("create trace directory: %w", err) + } + + // Install git hooks + hooksDir, err := hooks.Install(gitDir, false) + if err != nil { + return err + } + logger.Info(). + Str("path", hooksDir). + Msg("git hooks installed (post-commit, pre-push)") + + // Mark trace as initialized + if err := store.MarkTraceInitialized(); err != nil { + return fmt.Errorf("mark trace initialized: %w", err) + } + + // Resolve which providers to install. Pre-push will infer the + // owning provider per-session from the recorded SessionRecord, + // so the list isn't persisted anywhere — only the agent-side + // hook config files (.claude/settings.json, .cursor/hooks.json) + // determine which providers can register sessions. + selected := selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag) + selectedProviders := providers.ByNames(selected) + if len(selectedProviders) == 0 { + return fmt.Errorf("no trace providers selected") + } + + for _, p := range selectedProviders { + if err := p.InstallHooks(repoRoot); err != nil { + logger.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") + continue + } + logger.Info().Str("provider", p.Name()).Msg("agent hooks installed") + } + + logger.Info(). + Str("project", project). + Str("workflow", workflowName). + Strs("providers", selected). + Msg("trace initialized") + + return nil + }, + } + + cmd.Flags().StringVar(&project, "project", "", "chainloop project name") + cmd.Flags().String("workflow", "", "chainloop workflow name used for trace attestations (defaults to \"ai-coding-session\")") + cmd.Flags().Bool("require-trace", false, "block pushes when attestation fails for AI-assisted commits") + cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)") + cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks") + cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks") + + return cmd +} + +// selectedTraceProviders resolves the provider names to install based on the +// --claude, --cursor, and --opencode flags. When none is set, Claude Code is +// used as the default so existing users get the same behavior. +func selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag bool) []string { + if !claudeFlag && !cursorFlag && !opencodeFlag { + return []string{providers.DefaultProvider} + } + + var out []string + if claudeFlag { + out = append(out, claude.Name) + } + if cursorFlag { + out = append(out, cursor.Name) + } + if opencodeFlag { + out = append(out, opencode.Name) + } + + return out +} diff --git a/app/cli/cmd/trace_run.go b/app/cli/cmd/trace_run.go new file mode 100644 index 000000000..474e7ecba --- /dev/null +++ b/app/cli/cmd/trace_run.go @@ -0,0 +1,132 @@ +// +// 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 ( + "errors" + "fmt" + "os" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/spf13/cobra" +) + +const traceRunLong = `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.` + +// newTraceRunCmd creates the `trace run` subcommand. +func newTraceRunCmd() *cobra.Command { + var ( + projectFlag string + workflowFlag string + versionFlag string + claudeFlag bool + cursorFlag bool + opencodeFlag bool + ) + + cmd := &cobra.Command{ + Use: "run -- [args...]", + Short: "Run a single-shot AI coding session and attest it", + Long: traceRunLong, + Args: cobra.MinimumNArgs(1), + 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 + // carries which of the two it is, and an out-of-tree one disables + // the git hooks, which git could never invoke anyway. + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + + var store *state.Store + switch { + case err != nil && !errors.Is(err, tracegit.ErrNotARepository): + // A repository is there but unusable — don't paper over it + // by silently dropping commit attribution. + return err + case err != nil: + cwd, cwdErr := os.Getwd() + if cwdErr != nil { + return fmt.Errorf("get working directory: %w", cwdErr) + } + + stateDir, dirErr := state.NonGitDir(cwd) + if dirErr != nil { + return dirErr + } + + store, repoRoot = state.NewOutOfTreeStore(stateDir), cwd + logger.Info().Msg("no git repository found; recording the session without commit attribution") + default: + 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") + 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") + } + + return action.TraceRun(cmd.Context(), logger, action.TraceRunOpts{ + Store: store, + RepoRoot: repoRoot, + Providers: selectedTraceProviders(claudeFlag, cursorFlag, opencodeFlag), + Command: args, + ProjectName: projectFlag, + Organization: organization, + WorkflowName: workflowFlag, + ProjectVersion: versionFlag, + ActionOpts: ActionOpts, + CLIVersion: Version, + }) + }, + } + + 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(&versionFlag, "version", "", "chainloop project version (optional; defaults to the latest version)") + cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)") + cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks") + cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks") + + _ = cmd.MarkFlagRequired("project") + _ = cmd.MarkFlagRequired("workflow") + + return cmd +} diff --git a/app/cli/cmd/trace_uninstall.go b/app/cli/cmd/trace_uninstall.go new file mode 100644 index 000000000..a50dd0188 --- /dev/null +++ b/app/cli/cmd/trace_uninstall.go @@ -0,0 +1,60 @@ +// +// 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 ( + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/spf13/cobra" +) + +// newTraceUninstallCmd creates the trace uninstall subcommand. +func newTraceUninstallCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Remove trace git hooks and configuration", + Annotations: map[string]string{ + "skipActionOptsInit": "true", + }, + RunE: func(_ *cobra.Command, _ []string) error { + gitDir, err := tracegit.FindGitDir() + if err != nil { + return err + } + + if !yes { + if !confirmationPrompt("This will remove trace git hooks and configuration. Are you sure?") { + return nil + } + } + + repoRoot, _ := tracegit.RepoRoot() + if err := action.CleanupTrace(state.NewGitStore(gitDir), repoRoot, logger); err != nil { + return err + } + logger.Info().Msg("trace uninstalled") + + return nil + }, + } + + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompt") + + return cmd +} diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 728849efc..3093ca274 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3234,6 +3234,202 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +## chainloop trace + +AI coding session tracing and attestation + +Options + +``` +-h, --help help for trace +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop trace help + +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type trace help [path to command] for full details. + +``` +chainloop trace help [command] [flags] +``` + +Options + +``` +-h, --help help for help +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop trace init + +Initialize git hooks for automatic AI session tracing + +Synopsis + +Initialize git hooks that automatically trace AI coding sessions and +create Chainloop attestations when you push. + +This installs the managed git hooks plus agent-specific hooks for the +selected providers. Pass --claude, --cursor, and/or --opencode to pick +providers; when none is set, Claude Code is used as the default. + +Pass --org to pin the Chainloop organization trace attestations target; the +value is saved to .chainloop.yml and overrides the CLI default on every push. + +Pass --workflow to override the workflow name used when initializing trace +attestations. The value is saved to .chainloop.yml and defaults to +"ai-coding-session" when unset. + +``` +chainloop trace init [flags] +``` + +Options + +``` +--claude install Claude Code hooks (default when no provider flag is set) +--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") +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop trace run + +Run a single-shot AI coding session and attest it + +Synopsis + +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. + +``` +chainloop trace run -- [args...] [flags] +``` + +Options + +``` +--claude install Claude Code hooks (default when no provider flag is set) +--cursor install Cursor hooks +-h, --help help for run +--opencode install opencode hooks +--project string chainloop project name (required; .chainloop.yml is ignored) +--version string chainloop project version (optional; defaults to the latest version) +--workflow string chainloop workflow name used for trace attestations (required; .chainloop.yml is ignored) +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop trace uninstall + +Remove trace git hooks and configuration + +``` +chainloop trace uninstall [flags] +``` + +Options + +``` +-h, --help help for uninstall +-y, --yes skip confirmation prompt +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +``` + ## chainloop version Command line version diff --git a/app/cli/internal/trace/attribution/diff.go b/app/cli/internal/trace/attribution/diff.go new file mode 100644 index 000000000..0e0f7c846 --- /dev/null +++ b/app/cli/internal/trace/attribution/diff.go @@ -0,0 +1,104 @@ +// +// 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 attribution + +import ( + "bytes" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/sergi/go-diff/diffmatchpatch" +) + +// ComputeLineRanges computes which line ranges in "after" are new or modified +// compared to "before". Returns 1-indexed, inclusive line ranges plus a +// `changed` flag that is true whenever before and after differ — including +// deletion-only changes where no "after" ranges exist. +// If before is nil, the entire file is considered new. +func ComputeLineRanges(before, after []byte) ([]aicodingsession.LineRange, bool) { + if bytes.Equal(before, after) { + return nil, false + } + + afterStr := string(after) + if afterStr == "" { + // Deletion-only: before had content, after is empty. + return nil, true + } + + if before == nil { + lineCount := strings.Count(afterStr, "\n") + if !strings.HasSuffix(afterStr, "\n") { + lineCount++ + } + return []aicodingsession.LineRange{{Start: 1, End: lineCount}}, true + } + + beforeStr := string(before) + dmp := diffmatchpatch.New() + beforeChars, afterChars, lines := dmp.DiffLinesToChars(beforeStr, afterStr) + diffs := dmp.DiffMain(beforeChars, afterChars, false) + diffs = dmp.DiffCharsToLines(diffs, lines) + diffs = dmp.DiffCleanupSemantic(diffs) + + var ranges []aicodingsession.LineRange + afterLine := 1 + + for _, d := range diffs { + lineCount := strings.Count(d.Text, "\n") + if len(d.Text) > 0 && !strings.HasSuffix(d.Text, "\n") { + lineCount++ + } + + switch d.Type { + case diffmatchpatch.DiffEqual: + afterLine += lineCount + case diffmatchpatch.DiffInsert: + if lineCount > 0 { + ranges = append(ranges, aicodingsession.LineRange{ + Start: afterLine, + End: afterLine + lineCount - 1, + }) + afterLine += lineCount + } + case diffmatchpatch.DiffDelete: + // Lines removed from "before" — don't advance afterLine. + } + } + + return mergeAdjacentRanges(ranges), true +} + +// mergeAdjacentRanges merges overlapping or adjacent line ranges. +func mergeAdjacentRanges(ranges []aicodingsession.LineRange) []aicodingsession.LineRange { + if len(ranges) <= 1 { + return ranges + } + + merged := []aicodingsession.LineRange{ranges[0]} + for _, r := range ranges[1:] { + last := &merged[len(merged)-1] + if r.Start <= last.End+1 { + if r.End > last.End { + last.End = r.End + } + } else { + merged = append(merged, r) + } + } + + return merged +} diff --git a/app/cli/internal/trace/attribution/diff_test.go b/app/cli/internal/trace/attribution/diff_test.go new file mode 100644 index 000000000..a5f0d5301 --- /dev/null +++ b/app/cli/internal/trace/attribution/diff_test.go @@ -0,0 +1,135 @@ +// +// 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 attribution + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" +) + +func TestComputeLineRanges(t *testing.T) { + t.Run("nil before means entire file is new", func(t *testing.T) { + after := []byte("line1\nline2\nline3\n") + ranges, changed := ComputeLineRanges(nil, after) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 3}}, ranges) + }) + + t.Run("both nil is unchanged", func(t *testing.T) { + ranges, changed := ComputeLineRanges(nil, nil) + assert.False(t, changed) + assert.Nil(t, ranges) + }) + + t.Run("empty after with content before is deletion-only", func(t *testing.T) { + ranges, changed := ComputeLineRanges([]byte("old\n"), []byte("")) + assert.True(t, changed) + assert.Nil(t, ranges) + }) + + t.Run("identical content is unchanged", func(t *testing.T) { + content := []byte("line1\nline2\n") + ranges, changed := ComputeLineRanges(content, content) + assert.False(t, changed) + assert.Nil(t, ranges) + }) + + t.Run("deletion-only edit is changed with no ranges", func(t *testing.T) { + before := []byte("line1\nline2\nline3\n") + after := []byte("line1\nline3\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + assert.Empty(t, ranges) + }) + + t.Run("appended lines detected", func(t *testing.T) { + before := []byte("line1\nline2\n") + after := []byte("line1\nline2\nline3\nline4\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 3, End: 4}}, ranges) + }) + + t.Run("inserted lines in middle", func(t *testing.T) { + before := []byte("line1\nline3\n") + after := []byte("line1\nline2\nline3\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 2, End: 2}}, ranges) + }) + + t.Run("replaced lines detected", func(t *testing.T) { + before := []byte("line1\nold\nline3\n") + after := []byte("line1\nnew\nline3\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 2, End: 2}}, ranges) + }) + + t.Run("new file without trailing newline", func(t *testing.T) { + ranges, changed := ComputeLineRanges(nil, []byte("single line")) + assert.True(t, changed) + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 1}}, ranges) + }) + + t.Run("multiple separate changes", func(t *testing.T) { + before := []byte("a\nb\nc\nd\ne\n") + after := []byte("a\nX\nc\nY\ne\n") + ranges, changed := ComputeLineRanges(before, after) + assert.True(t, changed) + // The diff library may merge nearby changes; verify lines 2 and 4 are covered + assert.NotEmpty(t, ranges) + assert.Equal(t, 2, ranges[0].Start) + assert.Equal(t, 4, ranges[len(ranges)-1].End) + }) +} + +func TestMergeAdjacentRanges(t *testing.T) { + t.Run("empty returns empty", func(t *testing.T) { + assert.Nil(t, mergeAdjacentRanges(nil)) + }) + + t.Run("single range unchanged", func(t *testing.T) { + ranges := []aicodingsession.LineRange{{Start: 1, End: 3}} + assert.Equal(t, ranges, mergeAdjacentRanges(ranges)) + }) + + t.Run("adjacent ranges merged", func(t *testing.T) { + ranges := []aicodingsession.LineRange{ + {Start: 1, End: 3}, + {Start: 4, End: 6}, + } + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 6}}, mergeAdjacentRanges(ranges)) + }) + + t.Run("overlapping ranges merged", func(t *testing.T) { + ranges := []aicodingsession.LineRange{ + {Start: 1, End: 5}, + {Start: 3, End: 8}, + } + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 8}}, mergeAdjacentRanges(ranges)) + }) + + t.Run("non-adjacent ranges kept separate", func(t *testing.T) { + ranges := []aicodingsession.LineRange{ + {Start: 1, End: 3}, + {Start: 5, End: 7}, + } + assert.Equal(t, ranges, mergeAdjacentRanges(ranges)) + }) +} diff --git a/app/cli/internal/trace/attribution/edits.go b/app/cli/internal/trace/attribution/edits.go new file mode 100644 index 000000000..41fad0521 --- /dev/null +++ b/app/cli/internal/trace/attribution/edits.go @@ -0,0 +1,75 @@ +// +// 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 attribution + +import ( + "bytes" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +// ReverseApplyEdits reconstructs the "before" content of a file given its +// "after" content and the sequence of old_string → new_string replacements +// that were applied to produce it. Edits are reversed in reverse order. +// +// Best-effort and inherently ambiguous when new_string is not unique in +// the current content: bytes.Index always picks the first occurrence, +// which can be the wrong one if the same literal appears more than once. +// Cursor's afterFileEdit edits are typically context-rich enough that +// this rarely misfires in practice, and the worst-case outcome is a +// downstream line-range comparison reporting noisy ranges rather than a +// crash — but callers should treat the reconstructed bytes as a hint, +// not ground truth. +// +// If an edit's new_string cannot be located in the current content +// (because a later edit overlapped it, or the file was touched +// externally), that edit is skipped. When the resulting "before" equals +// "after" we return nil so callers can fall back to other reconstruction +// strategies. +func ReverseApplyEdits(after []byte, edits []trace.HookEdit) []byte { + if len(edits) == 0 { + return nil + } + + result := make([]byte, len(after)) + copy(result, after) + + for i := len(edits) - 1; i >= 0; i-- { + e := edits[i] + if e.NewString == "" { + continue + } + + newBytes := []byte(e.NewString) + idx := bytes.Index(result, newBytes) + if idx < 0 { + continue + } + + var b bytes.Buffer + b.Grow(len(result) - len(newBytes) + len(e.OldString)) + b.Write(result[:idx]) + b.WriteString(e.OldString) + b.Write(result[idx+len(newBytes):]) + result = b.Bytes() + } + + if bytes.Equal(result, after) { + return nil + } + + return result +} diff --git a/app/cli/internal/trace/attribution/edits_test.go b/app/cli/internal/trace/attribution/edits_test.go new file mode 100644 index 000000000..6c33f8d16 --- /dev/null +++ b/app/cli/internal/trace/attribution/edits_test.go @@ -0,0 +1,88 @@ +// +// 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 attribution + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/stretchr/testify/require" +) + +func TestReverseApplyEdits(t *testing.T) { + tests := []struct { + name string + after string + edits []trace.HookEdit + want string // expected reconstructed content when wantNil is false + wantNil bool // explicit "result must be nil"; distinct from a legitimate empty result + }{ + { + name: "no edits", + after: "hello", + edits: nil, + wantNil: true, + }, + { + name: "single replace", + after: "hello world", + edits: []trace.HookEdit{{OldString: "there", NewString: "world"}}, + want: "hello there", + }, + { + name: "multiple sequential edits reversed in order", + after: "foo BAR baz QUUX", + edits: []trace.HookEdit{ + {OldString: "bar", NewString: "BAR"}, + {OldString: "quux", NewString: "QUUX"}, + }, + want: "foo bar baz quux", + }, + { + name: "missing new_string is skipped", + after: "hello world", + edits: []trace.HookEdit{ + {OldString: "xyz", NewString: "not present"}, + {OldString: "there", NewString: "world"}, + }, + want: "hello there", + }, + { + name: "insertion (empty old) is reversed to deletion", + after: "prefix-content", + edits: []trace.HookEdit{{OldString: "", NewString: "prefix-"}}, + want: "content", + }, + { + name: "deletion (empty new) skipped because new_string empty", + after: "abc", + edits: []trace.HookEdit{{OldString: "removed", NewString: ""}}, + wantNil: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ReverseApplyEdits([]byte(tc.after), tc.edits) + if tc.wantNil { + require.Nil(t, got) + + return + } + require.Equal(t, tc.want, string(got)) + }) + } +} diff --git a/app/cli/internal/trace/attribution/enrich.go b/app/cli/internal/trace/attribution/enrich.go new file mode 100644 index 000000000..a056dd845 --- /dev/null +++ b/app/cli/internal/trace/attribution/enrich.go @@ -0,0 +1,60 @@ +// +// 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 attribution + +import ( + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// Enrich adds AI/human attribution to code changes based on AI line range data. +// The sessionID identifies which session produced the changes, and aiFiles maps +// file paths to the line ranges modified by the AI in that session. +func Enrich(sessionID string, aiFiles map[string][]aicodingsession.LineRange, changes *aicodingsession.CodeChanges) { + if len(aiFiles) == 0 { + // No AI line data — all changes are human + changes.HumanLinesAdded = changes.LinesAdded + changes.HumanLinesRemoved = changes.LinesRemoved + for i := range changes.Files { + changes.Files[i].Attribution = trace.AttributionHuman + } + + return + } + + for i := range changes.Files { + f := &changes.Files[i] + // An empty ranges slice is valid: the agent only deleted lines. + if aiRanges, ok := aiFiles[f.Path]; ok { + f.Attribution = trace.AttributionAI + f.LineRanges = aiRanges + f.SessionIDs = []string{sessionID} + // Count AI lines as the lines covered by AI ranges, capped at the file's diff totals + aiLineCount := 0 + for _, r := range aiRanges { + aiLineCount += r.End - r.Start + 1 + } + aiAdded := min(aiLineCount, f.LinesAdded) + changes.AILinesAdded += aiAdded + changes.HumanLinesAdded += f.LinesAdded - aiAdded + changes.AILinesRemoved += f.LinesRemoved + } else { + f.Attribution = trace.AttributionHuman + changes.HumanLinesAdded += f.LinesAdded + changes.HumanLinesRemoved += f.LinesRemoved + } + } +} diff --git a/app/cli/internal/trace/attribution/enrich_test.go b/app/cli/internal/trace/attribution/enrich_test.go new file mode 100644 index 000000000..d926d785d --- /dev/null +++ b/app/cli/internal/trace/attribution/enrich_test.go @@ -0,0 +1,138 @@ +// +// 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 attribution + +import ( + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" +) + +func TestEnrich(t *testing.T) { + t.Run("no AI data attributes all changes to human", func(t *testing.T) { + changes := &aicodingsession.CodeChanges{ + LinesAdded: 10, + LinesRemoved: 4, + Files: []aicodingsession.FileChange{ + {Path: "a.go", LinesAdded: 10, LinesRemoved: 4}, + }, + } + + Enrich("sess", nil, changes) + + assert.Equal(t, 10, changes.HumanLinesAdded) + assert.Equal(t, 4, changes.HumanLinesRemoved) + assert.Equal(t, 0, changes.AILinesAdded) + assert.Equal(t, 0, changes.AILinesRemoved) + assert.Equal(t, trace.AttributionHuman, changes.Files[0].Attribution) + }) + + t.Run("shell-generated file recorded as AI is not counted as human (PFM-6684)", func(t *testing.T) { + // Regression: a 177,764-line fixture the agent produced via a shell + // command, once recorded in the AI-line map (whole-file range), must be + // attributed to AI — not dumped into the human bucket where it drowns + // the small edits and yields a misleading 0% AI. + aiFiles := map[string][]aicodingsession.LineRange{ + "policies/foo.rego": {{Start: 1, End: 222}}, + "policies/integration-tests/materials/sbom.cyclonedx.json": {{Start: 1, End: 177764}}, + } + changes := &aicodingsession.CodeChanges{ + LinesAdded: 177986, + Files: []aicodingsession.FileChange{ + {Path: "policies/foo.rego", Status: "modified", LinesAdded: 222}, + {Path: "policies/integration-tests/materials/sbom.cyclonedx.json", Status: "created", LinesAdded: 177764}, + }, + } + + Enrich("sess", aiFiles, changes) + + assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) + assert.Equal(t, trace.AttributionAI, changes.Files[1].Attribution) + assert.Equal(t, 177986, changes.AILinesAdded) + assert.Equal(t, 0, changes.HumanLinesAdded) + }) + + t.Run("file with AI ranges is attributed to AI", func(t *testing.T) { + aiFiles := map[string][]aicodingsession.LineRange{ + "a.go": {{Start: 1, End: 5}}, + } + changes := &aicodingsession.CodeChanges{ + LinesAdded: 5, + LinesRemoved: 2, + Files: []aicodingsession.FileChange{ + {Path: "a.go", LinesAdded: 5, LinesRemoved: 2}, + }, + } + + Enrich("sess", aiFiles, changes) + + assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) + assert.Equal(t, []string{"sess"}, changes.Files[0].SessionIDs) + assert.Equal(t, 5, changes.AILinesAdded) + assert.Equal(t, 2, changes.AILinesRemoved) + assert.Equal(t, 0, changes.HumanLinesAdded) + assert.Equal(t, 0, changes.HumanLinesRemoved) + }) + + t.Run("deletion-only file with empty ranges is attributed to AI", func(t *testing.T) { + // AI removed lines from a file — there are no "after" ranges, but + // the file is still listed in the session's attribution map. + aiFiles := map[string][]aicodingsession.LineRange{ + "a.go": nil, + } + changes := &aicodingsession.CodeChanges{ + LinesAdded: 0, + LinesRemoved: 4, + Files: []aicodingsession.FileChange{ + {Path: "a.go", LinesAdded: 0, LinesRemoved: 4}, + }, + } + + Enrich("sess", aiFiles, changes) + + assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) + assert.Equal(t, []string{"sess"}, changes.Files[0].SessionIDs) + assert.Equal(t, 0, changes.AILinesAdded) + assert.Equal(t, 4, changes.AILinesRemoved) + assert.Equal(t, 0, changes.HumanLinesAdded) + assert.Equal(t, 0, changes.HumanLinesRemoved) + }) + + t.Run("files not in aiFiles are attributed to human", func(t *testing.T) { + aiFiles := map[string][]aicodingsession.LineRange{ + "a.go": {{Start: 1, End: 2}}, + } + changes := &aicodingsession.CodeChanges{ + LinesAdded: 6, + LinesRemoved: 3, + Files: []aicodingsession.FileChange{ + {Path: "a.go", LinesAdded: 2, LinesRemoved: 1}, + {Path: "b.go", LinesAdded: 4, LinesRemoved: 2}, + }, + } + + Enrich("sess", aiFiles, changes) + + assert.Equal(t, trace.AttributionAI, changes.Files[0].Attribution) + assert.Equal(t, trace.AttributionHuman, changes.Files[1].Attribution) + assert.Equal(t, 2, changes.AILinesAdded) + assert.Equal(t, 1, changes.AILinesRemoved) + assert.Equal(t, 4, changes.HumanLinesAdded) + assert.Equal(t, 2, changes.HumanLinesRemoved) + }) +} diff --git a/app/cli/internal/trace/attribution/generated.go b/app/cli/internal/trace/attribution/generated.go new file mode 100644 index 000000000..615b9f576 --- /dev/null +++ b/app/cli/internal/trace/attribution/generated.go @@ -0,0 +1,49 @@ +// +// 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 attribution + +import ( + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// FilterGenerated removes files matched by isGenerated from changes.Files and +// subtracts their contributions from the aggregate line and file-count totals, +// so generated code does not skew downstream AI/human attribution. A nil +// isGenerated leaves changes untouched. +func FilterGenerated(changes *aicodingsession.CodeChanges, isGenerated func(path string) bool) { + if isGenerated == nil { + return + } + filtered := changes.Files[:0] + for _, f := range changes.Files { + if isGenerated(f.Path) { + changes.LinesAdded -= f.LinesAdded + changes.LinesRemoved -= f.LinesRemoved + switch f.Status { + case "created": + changes.FilesCreated-- + case "deleted": + changes.FilesDeleted-- + default: + changes.FilesModified-- + } + + continue + } + filtered = append(filtered, f) + } + changes.Files = filtered +} diff --git a/app/cli/internal/trace/attribution/generated_test.go b/app/cli/internal/trace/attribution/generated_test.go new file mode 100644 index 000000000..fccd28800 --- /dev/null +++ b/app/cli/internal/trace/attribution/generated_test.go @@ -0,0 +1,146 @@ +// +// 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 attribution + +import ( + "os" + "path/filepath" + "testing" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterGenerated(t *testing.T) { + t.Run("nil matcher leaves changes untouched", func(t *testing.T) { + changes := &aicodingsession.CodeChanges{ + FilesModified: 1, + LinesAdded: 5, + Files: []aicodingsession.FileChange{{Path: "a.go", Status: "modified", LinesAdded: 5}}, + } + + FilterGenerated(changes, nil) + + assert.Len(t, changes.Files, 1) + assert.Equal(t, 5, changes.LinesAdded) + assert.Equal(t, 1, changes.FilesModified) + }) + + t.Run("generated created file is dropped and counters decremented", func(t *testing.T) { + isGenerated := func(path string) bool { return path == "gen/out.pb.go" } + changes := &aicodingsession.CodeChanges{ + FilesModified: 1, + FilesCreated: 1, + LinesAdded: 203, + LinesRemoved: 5, + Files: []aicodingsession.FileChange{ + {Path: "a.go", Status: "modified", LinesAdded: 3, LinesRemoved: 5}, + {Path: "gen/out.pb.go", Status: "created", LinesAdded: 200, LinesRemoved: 0}, + }, + } + + FilterGenerated(changes, isGenerated) + + assert.Len(t, changes.Files, 1) + assert.Equal(t, "a.go", changes.Files[0].Path) + assert.Equal(t, 3, changes.LinesAdded) + assert.Equal(t, 5, changes.LinesRemoved) + assert.Equal(t, 1, changes.FilesModified) + assert.Equal(t, 0, changes.FilesCreated) + }) + + t.Run("generated deleted file decrements FilesDeleted", func(t *testing.T) { + isGenerated := func(path string) bool { return path == "old.pb.go" } + changes := &aicodingsession.CodeChanges{ + FilesDeleted: 1, + LinesRemoved: 7, + Files: []aicodingsession.FileChange{ + {Path: "old.pb.go", Status: "deleted", LinesRemoved: 7}, + }, + } + + FilterGenerated(changes, isGenerated) + + assert.Empty(t, changes.Files) + assert.Equal(t, 0, changes.FilesDeleted) + assert.Equal(t, 0, changes.LinesRemoved) + }) + + t.Run("ent gitattributes rules drop generated ent code but keep schema and migrations", func(t *testing.T) { + dir := t.TempDir() + // Order matches the project .gitattributes: general rule first, then + // more-specific overrides (last match wins, per gitattributes spec). + writeGitAttributes(t, dir, + "backend/internal/data/ent/** linguist-generated=true\n"+ + "backend/internal/data/ent/migrate/migrations/** linguist-generated=false\n"+ + "backend/internal/data/ent/schema/* linguist-generated=false\n", + ) + + isGenerated := tracegit.NewGoGitClient().GeneratedMatcher(dir) + + changes := &aicodingsession.CodeChanges{ + FilesCreated: 3, + FilesModified: 4, + FilesDeleted: 1, + LinesAdded: 100 + 50 + 200 + 10 + 7 + 25 + 40, + LinesRemoved: 5 + 2 + 30 + 1 + 0 + 3 + 8 + 4, + Files: []aicodingsession.FileChange{ + // Generated ent code — should be dropped. + {Path: "backend/internal/data/ent/client.go", Status: "modified", LinesAdded: 100, LinesRemoved: 5}, + {Path: "backend/internal/data/ent/account.go", Status: "modified", LinesAdded: 50, LinesRemoved: 2}, + // Generated files directly under migrate/ — must also be dropped (the bug we're locking in). + {Path: "backend/internal/data/ent/migrate/migrate.go", Status: "modified", LinesAdded: 200, LinesRemoved: 30}, + {Path: "backend/internal/data/ent/migrate/schema.go", Status: "deleted", LinesAdded: 0, LinesRemoved: 8}, + // Hand-managed migrations — must be kept. + {Path: "backend/internal/data/ent/migrate/migrations/20240602211927.sql", Status: "created", LinesAdded: 10, LinesRemoved: 1}, + {Path: "backend/internal/data/ent/migrate/migrations/atlas.sum", Status: "modified", LinesAdded: 7, LinesRemoved: 0}, + // Schema source — must be kept. + {Path: "backend/internal/data/ent/schema/ai_coding_session.go", Status: "created", LinesAdded: 25, LinesRemoved: 3}, + // Unrelated path — must be kept. + {Path: "backend/internal/service/evidence.go", Status: "created", LinesAdded: 40, LinesRemoved: 4}, + }, + } + + FilterGenerated(changes, isGenerated) + + gotPaths := make([]string, 0, len(changes.Files)) + for _, f := range changes.Files { + gotPaths = append(gotPaths, f.Path) + } + assert.ElementsMatch(t, []string{ + "backend/internal/data/ent/migrate/migrations/20240602211927.sql", + "backend/internal/data/ent/migrate/migrations/atlas.sum", + "backend/internal/data/ent/schema/ai_coding_session.go", + "backend/internal/service/evidence.go", + }, gotPaths) + + // Surviving line totals: 10+7+25+40 added, 1+0+3+4 removed. + assert.Equal(t, 82, changes.LinesAdded) + assert.Equal(t, 8, changes.LinesRemoved) + // Surviving status counts: 3 created (sql, schema, evidence), 1 modified (atlas.sum), 0 deleted. + assert.Equal(t, 3, changes.FilesCreated) + assert.Equal(t, 1, changes.FilesModified) + assert.Equal(t, 0, changes.FilesDeleted) + }) +} + +func writeGitAttributes(t *testing.T, dir, content string) { + t.Helper() + path := filepath.Join(dir, ".gitattributes") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600), "write .gitattributes") +} diff --git a/app/cli/internal/trace/attribution/shell.go b/app/cli/internal/trace/attribution/shell.go new file mode 100644 index 000000000..4f55b46da --- /dev/null +++ b/app/cli/internal/trace/attribution/shell.go @@ -0,0 +1,42 @@ +// +// 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 attribution + +import "sort" + +// ChangedPaths diffs two working-tree signatures (repo-relative path → content +// hash) captured before and after a shell command, returning the paths the +// command created or modified (changed) and the paths it removed (deleted). +// Both slices are sorted for deterministic recording. Used to attribute +// files touched by agent-run shell commands, which never fire the file-writing +// tool hooks. +func ChangedPaths(before, after map[string]string) (changed, deleted []string) { + for path, hash := range after { + if prev, ok := before[path]; !ok || prev != hash { + changed = append(changed, path) + } + } + for path := range before { + if _, ok := after[path]; !ok { + deleted = append(deleted, path) + } + } + + sort.Strings(changed) + sort.Strings(deleted) + + return changed, deleted +} diff --git a/app/cli/internal/trace/attribution/shell_test.go b/app/cli/internal/trace/attribution/shell_test.go new file mode 100644 index 000000000..943a35e2b --- /dev/null +++ b/app/cli/internal/trace/attribution/shell_test.go @@ -0,0 +1,52 @@ +// +// 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 attribution + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChangedPaths(t *testing.T) { + t.Run("detects created, modified, and deleted paths", func(t *testing.T) { + before := map[string]string{"a": "1", "b": "2", "c": "3"} + after := map[string]string{"a": "1", "b": "9", "d": "4"} + + changed, deleted := ChangedPaths(before, after) + + assert.Equal(t, []string{"b", "d"}, changed) // b modified, d created + assert.Equal(t, []string{"c"}, deleted) // c removed + }) + + t.Run("empty when signatures are equal", func(t *testing.T) { + sig := map[string]string{"a": "1", "b": "2"} + + changed, deleted := ChangedPaths(sig, sig) + + assert.Empty(t, changed) + assert.Empty(t, deleted) + }) + + t.Run("all created from empty before", func(t *testing.T) { + after := map[string]string{"b": "2", "a": "1"} + + changed, deleted := ChangedPaths(nil, after) + + assert.Equal(t, []string{"a", "b"}, changed) // sorted + assert.Empty(t, deleted) + }) +} diff --git a/app/cli/internal/trace/claude/hooks.go b/app/cli/internal/trace/claude/hooks.go new file mode 100644 index 000000000..ff25e943b --- /dev/null +++ b/app/cli/internal/trace/claude/hooks.go @@ -0,0 +1,325 @@ +// +// 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 claude + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +const ( + hookMarker = "chainloop trace hook claude" + settingsFile = ".claude/settings.json" + + // Claude Code hook event names. + eventSessionStart = "SessionStart" + eventPreToolUse = "PreToolUse" + eventPostToolUse = "PostToolUse" + eventSessionEnd = "SessionEnd" +) + +// fileWritingTools is the single source of truth for Claude tool names that modify files. +var fileWritingTools = []string{"Edit", "Write", "MultiEdit"} + +// commandTools are Claude tool names that run shell commands. They can create +// or modify arbitrary files, so their changes are captured via a before/after +// working-tree snapshot rather than a per-file snapshot. +var commandTools = []string{"Bash"} + +// hookToolMatcher is the pipe-separated matcher for the pre/post-tool-use hooks. +// It covers both file-writing and command tools; the handler branches on which +// kind fired. +var hookToolMatcher = strings.Join(append(append([]string{}, fileWritingTools...), commandTools...), "|") + +type hookEvent struct { + event string + command string + matcher string // optional: tool name matcher +} + +var hookEvents = []hookEvent{ + {eventSessionStart, "chainloop trace hook claude session-start", ""}, + {eventPreToolUse, "chainloop trace hook claude pre-tool-use", hookToolMatcher}, + {eventPostToolUse, "chainloop trace hook claude post-tool-use", hookToolMatcher}, + {eventSessionEnd, "chainloop trace hook claude session-end", ""}, +} + +// SettingsFile returns the absolute path to .claude/settings.json for the given repo. +func (p *Provider) SettingsFile(repoRoot string) string { + return filepath.Join(repoRoot, settingsFile) +} + +// InstallHooks adds Chainloop hooks to .claude/settings.json. +func (p *Provider) InstallHooks(repoRoot string) error { + return p.installHookEvents(repoRoot, hookEvents) +} + +// InstallHooksForTraceRun installs every hook except SessionEnd; trace +// run drives end-of-session itself. +func (p *Provider) InstallHooksForTraceRun(repoRoot string) error { + events := make([]hookEvent, 0, len(hookEvents)) + for _, h := range hookEvents { + if h.event == eventSessionEnd { + continue + } + events = append(events, h) + } + + return p.installHookEvents(repoRoot, events) +} + +func (p *Provider) installHookEvents(repoRoot string, events []hookEvent) error { + settingsPath := filepath.Join(repoRoot, settingsFile) + + settings, err := readJSONFile(settingsPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + hooks = make(map[string]any) + } + + changed := false + for _, h := range events { + if addHookEntry(hooks, h.event, h.command, h.matcher) { + changed = true + } + } + + if !changed { + return nil + } + + settings["hooks"] = hooks + + return writeJSONFile(settingsPath, settings) +} + +// UninstallHooks removes Chainloop hooks from .claude/settings.json. +func (p *Provider) UninstallHooks(repoRoot string) error { + settingsPath := filepath.Join(repoRoot, settingsFile) + + settings, err := readJSONFile(settingsPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + return nil + } + + changed := false + for _, h := range hookEvents { + if removeHookEntry(hooks, h.event) { + changed = true + } + } + + if !changed { + return nil + } + + // Clean up empty hooks map + if len(hooks) == 0 { + delete(settings, "hooks") + } + + // Delete file if empty + if len(settings) == 0 { + return os.Remove(settingsPath) + } + + return writeJSONFile(settingsPath, settings) +} + +// maxHookPayloadBytes caps hook payload reads to defend against runaway or +// malformed payloads. +const maxHookPayloadBytes = 16 * 1024 * 1024 + +// ReadHookInput reads and parses the Claude Code hook JSON from the given reader. +func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { + data, err := io.ReadAll(io.LimitReader(r, maxHookPayloadBytes)) + if err != nil { + return nil, err + } + + // Single unmarshal into a combined struct to avoid double-parsing. + // CursorVersion is read alongside the standard fields so we can detect + // Cursor-relayed events without a second pass. + var raw struct { + trace.HookInput + ToolInput struct { + FilePath string `json:"file_path"` + } `json:"tool_input"` + CursorVersion string `json:"cursor_version"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + // When Cursor is the runtime relaying the event (both .claude/settings.json + // and .cursor/hooks.json are installed), every payload — including the + // preToolUse/postToolUse it forwards through Claude's hook config — carries + // cursor_version. Native Claude Code never injects that field. Returning + // an empty input makes the Claude handler bail out so the cursor provider's + // afterFileEdit owns the session and recording is not duplicated. + if raw.CursorVersion != "" { + return &trace.HookInput{}, nil + } + + input := raw.HookInput + if raw.ToolInput.FilePath != "" { + input.FilePath = raw.ToolInput.FilePath + } + + return &input, nil +} + +func addHookEntry(hooks map[string]any, event, command, matcher string) bool { + entries, _ := hooks[event].([]any) + + // Check if a chainloop hook already exists for this event + for i, entry := range entries { + if !entryContainsChainloopHook(entry) { + continue + } + // Chainloop entry exists — check if it needs updating (e.g., matcher changed) + m, _ := entry.(map[string]any) + existingMatcher, _ := m["matcher"].(string) + if existingMatcher == matcher { + return false // already up to date + } + // Replace the entry with updated version + entries[i] = buildHookEntry(command, matcher) + hooks[event] = entries + return true + } + + // No existing chainloop entry — add new one + hooks[event] = append(entries, buildHookEntry(command, matcher)) + return true +} + +func buildHookEntry(command, matcher string) map[string]any { + entry := map[string]any{ + "hooks": []any{ + map[string]any{ + "type": "command", + "command": command, + }, + }, + } + if matcher != "" { + entry["matcher"] = matcher + } + return entry +} + +func removeHookEntry(hooks map[string]any, event string) bool { + entries, _ := hooks[event].([]any) + if len(entries) == 0 { + return false + } + + var kept []any + for _, entry := range entries { + if !entryContainsChainloopHook(entry) { + kept = append(kept, entry) + } + } + + if len(kept) == len(entries) { + return false + } + + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept + } + + return true +} + +func entryContainsChainloopHook(entry any) bool { + m, ok := entry.(map[string]any) + if !ok { + return false + } + + innerHooks, _ := m["hooks"].([]any) + for _, h := range innerHooks { + hm, ok := h.(map[string]any) + if !ok { + continue + } + + cmd, _ := hm["command"].(string) + if strings.Contains(cmd, hookMarker) { + return true + } + } + + return false +} + +func readJSONFile(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return make(map[string]any), nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + // A file containing `null` unmarshals successfully into a nil map, which + // panics on assignment. + if result == nil { + result = make(map[string]any) + } + + return result, nil +} + +func writeJSONFile(path string, data map[string]any) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal JSON: %w", err) + } + + content = append(content, '\n') + + return os.WriteFile(path, content, 0600) +} diff --git a/app/cli/internal/trace/claude/hooks_test.go b/app/cli/internal/trace/claude/hooks_test.go new file mode 100644 index 000000000..c62484763 --- /dev/null +++ b/app/cli/internal/trace/claude/hooks_test.go @@ -0,0 +1,357 @@ +// +// 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 claude + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallHooks(t *testing.T) { + provider := New() + + t.Run("creates settings file with hooks", func(t *testing.T) { + repoRoot := t.TempDir() + + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + assert.Contains(t, hooks, "SessionStart") + assert.Contains(t, hooks, "PreToolUse") + assert.Contains(t, hooks, "PostToolUse") + + // Verify command contents + assertHookCommand(t, hooks, "SessionStart", "chainloop trace hook claude session-start") + assertHookCommand(t, hooks, "PreToolUse", "chainloop trace hook claude pre-tool-use") + assertHookCommand(t, hooks, "PostToolUse", "chainloop trace hook claude post-tool-use") + }) + + t.Run("installs PreToolUse and PostToolUse with matchers", func(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + // PreToolUse should have matcher + preEntries := hooks["PreToolUse"].([]any) + preEntry := preEntries[0].(map[string]any) + assert.Equal(t, "Edit|Write|MultiEdit|Bash", preEntry["matcher"]) + + // PostToolUse should have matcher + postEntries := hooks["PostToolUse"].([]any) + postEntry := postEntries[0].(map[string]any) + assert.Equal(t, "Edit|Write|MultiEdit|Bash", postEntry["matcher"]) + + // SessionStart should NOT have matcher + startEntries := hooks["SessionStart"].([]any) + startEntry := startEntries[0].(map[string]any) + _, hasMatcher := startEntry["matcher"] + assert.False(t, hasMatcher) + + // SessionEnd should NOT have matcher + endEntries := hooks["SessionEnd"].([]any) + endEntry := endEntries[0].(map[string]any) + _, hasMatcher = endEntry["matcher"] + assert.False(t, hasMatcher) + }) + + t.Run("upgrades existing hooks to add matcher", func(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, ".claude", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + + // Simulate old-format PreToolUse without matcher + existing := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{ + "hooks": []any{ + map[string]any{"type": "command", "command": "chainloop trace hook claude pre-tool-use"}, + }, + }, + }, + }, + } + data, _ := json.MarshalIndent(existing, "", " ") + require.NoError(t, os.WriteFile(settingsPath, data, 0600)) + + // Re-install should upgrade + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + preEntries := hooks["PreToolUse"].([]any) + require.Len(t, preEntries, 1) + preEntry := preEntries[0].(map[string]any) + assert.Equal(t, "Edit|Write|MultiEdit|Bash", preEntry["matcher"]) + }) + + t.Run("idempotent — running twice produces same result", func(t *testing.T) { + repoRoot := t.TempDir() + + require.NoError(t, provider.InstallHooks(repoRoot)) + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + // Should have exactly one entry per event + sessionStart := hooks["SessionStart"].([]any) + assert.Len(t, sessionStart, 1) + + preToolUse := hooks["PreToolUse"].([]any) + assert.Len(t, preToolUse, 1) + + postToolUse := hooks["PostToolUse"].([]any) + assert.Len(t, postToolUse, 1) + }) + + t.Run("preserves existing settings", func(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, ".claude", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + + existing := map[string]any{ + "customSetting": "value", + "hooks": map[string]any{ + "PostToolUse": []any{ + map[string]any{ + "matcher": "Edit|Write", + "hooks": []any{ + map[string]any{"type": "command", "command": "format.sh"}, + }, + }, + }, + }, + } + data, _ := json.MarshalIndent(existing, "", " ") + require.NoError(t, os.WriteFile(settingsPath, data, 0600)) + + require.NoError(t, provider.InstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + assert.Equal(t, "value", settings["customSetting"]) + + hooks := settings["hooks"].(map[string]any) + assert.Contains(t, hooks, "PostToolUse") + assert.Contains(t, hooks, "SessionStart") + assert.Contains(t, hooks, "PreToolUse") + }) +} + +func TestUninstallHooks(t *testing.T) { + provider := New() + + t.Run("removes chainloop entries", func(t *testing.T) { + repoRoot := t.TempDir() + + require.NoError(t, provider.InstallHooks(repoRoot)) + require.NoError(t, provider.UninstallHooks(repoRoot)) + + settingsPath := filepath.Join(repoRoot, ".claude", "settings.json") + _, err := os.Stat(settingsPath) + assert.True(t, os.IsNotExist(err), "empty settings file should be deleted") + }) + + t.Run("preserves other hook entries", func(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, ".claude", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + + existing := map[string]any{ + "hooks": map[string]any{ + "PostToolUse": []any{ + map[string]any{ + "hooks": []any{ + map[string]any{"type": "command", "command": "format.sh"}, + }, + }, + }, + }, + } + data, _ := json.MarshalIndent(existing, "", " ") + require.NoError(t, os.WriteFile(settingsPath, data, 0600)) + + require.NoError(t, provider.InstallHooks(repoRoot)) + require.NoError(t, provider.UninstallHooks(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + assert.Contains(t, hooks, "PostToolUse") + assert.NotContains(t, hooks, "SessionStart") + assert.NotContains(t, hooks, "PreToolUse") + }) + + t.Run("noop when file does not exist", func(t *testing.T) { + repoRoot := t.TempDir() + assert.NoError(t, provider.UninstallHooks(repoRoot)) + }) +} + +func TestReadHookInput(t *testing.T) { + provider := New() + + t.Run("parses valid input", func(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":"abc-123","hook_event_name":"SessionStart","tool_name":"Edit","cwd":"/some/path"}`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Equal(t, "SessionStart", input.HookEventName) + assert.Equal(t, "Edit", input.ToolName) + }) + + t.Run("extracts tool metadata", func(t *testing.T) { + r := bytes.NewBufferString(`{ + "session_id": "abc-123", + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": {"file_path": "/some/file.go", "old_string": "foo"} + }`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Equal(t, "PreToolUse", input.HookEventName) + assert.Equal(t, "Edit", input.ToolName) + assert.Equal(t, "/some/file.go", input.FilePath) + }) + + t.Run("handles missing tool_input gracefully", func(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":"abc-123","hook_event_name":"SessionStart"}`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Equal(t, "SessionStart", input.HookEventName) + assert.Empty(t, input.FilePath) + }) + + t.Run("returns empty for empty session ID", func(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":""}`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Empty(t, input.SessionID) + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + r := bytes.NewBufferString(`not json`) + _, err := provider.ReadHookInput(r) + assert.Error(t, err) + }) + + t.Run("bails on Cursor-relayed payloads", func(t *testing.T) { + // When .claude/settings.json and .cursor/hooks.json are both + // installed and the user is running inside Cursor, Cursor's + // runtime fires the Claude-flavored hooks too — every payload + // it relays carries cursor_version. We must skip those so the + // cursor provider's afterFileEdit owns the session and we don't + // double-record line ranges. + r := bytes.NewBufferString(`{ + "session_id": "e1cd0c12-07cb-495b-be1f-b6254bff24e8", + "hook_event_name": "preToolUse", + "tool_name": "Write", + "tool_input": {"file_path": "/repo/README.md"}, + "cursor_version": "3.2.16" + }`) + input, err := provider.ReadHookInput(r) + require.NoError(t, err) + assert.Empty(t, input.SessionID, "Cursor-relayed events must yield an empty input") + assert.Empty(t, input.FilePath) + }) +} + +func TestIsFileWritingTool(t *testing.T) { + provider := New() + + assert.True(t, provider.IsFileWritingTool("Edit")) + assert.True(t, provider.IsFileWritingTool("Write")) + assert.True(t, provider.IsFileWritingTool("MultiEdit")) + assert.False(t, provider.IsFileWritingTool("Read")) + assert.False(t, provider.IsFileWritingTool("Bash")) + assert.False(t, provider.IsFileWritingTool("")) +} + +func TestIsCommandTool(t *testing.T) { + provider := New() + + assert.True(t, provider.IsCommandTool("Bash")) + assert.False(t, provider.IsCommandTool("Edit")) + assert.False(t, provider.IsCommandTool("Write")) + assert.False(t, provider.IsCommandTool("")) +} + +func TestInstallHooksForTraceRun(t *testing.T) { + provider := New() + + t.Run("installs everything except SessionEnd", func(t *testing.T) { + repoRoot := t.TempDir() + + require.NoError(t, provider.InstallHooksForTraceRun(repoRoot)) + + settings := readSettings(t, repoRoot) + hooks := settings["hooks"].(map[string]any) + + assert.Contains(t, hooks, "SessionStart") + assert.Contains(t, hooks, "PreToolUse") + assert.Contains(t, hooks, "PostToolUse") + assert.NotContains(t, hooks, "SessionEnd", "trace run must not install SessionEnd; trace run drives end-of-session itself") + }) +} + +func TestInstallHooksOnNullSettings(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".claude"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, settingsFile), []byte("null\n"), 0600)) + + require.NoError(t, New().InstallHooks(repoRoot)) + assert.Contains(t, readSettings(t, repoRoot), "hooks") +} + +func readSettings(t *testing.T, repoRoot string) map[string]any { + t.Helper() + + data, err := os.ReadFile(filepath.Join(repoRoot, ".claude", "settings.json")) + require.NoError(t, err) + + var settings map[string]any + require.NoError(t, json.Unmarshal(data, &settings)) + + return settings +} + +func assertHookCommand(t *testing.T, hooks map[string]any, event, expectedCmd string) { + t.Helper() + + entries, ok := hooks[event].([]any) + require.True(t, ok, "event %s should be an array", event) + require.Len(t, entries, 1) + + entry := entries[0].(map[string]any) + innerHooks := entry["hooks"].([]any) + require.Len(t, innerHooks, 1) + + hook := innerHooks[0].(map[string]any) + assert.Equal(t, "command", hook["type"]) + assert.Equal(t, expectedCmd, hook["command"]) +} diff --git a/app/cli/internal/trace/claude/parse.go b/app/cli/internal/trace/claude/parse.go new file mode 100644 index 000000000..c9a2f65ad --- /dev/null +++ b/app/cli/internal/trace/claude/parse.go @@ -0,0 +1,422 @@ +// +// 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 claude + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// Pricing rates live in pricing.json (loaded by pricing.go via go:embed). + +const ( + maxScannerBuffer = 10 * 1024 * 1024 // 10 MB for large assistant messages + maxWarnings = 50 // cap to prevent unbounded growth from corrupt files +) + +// Transcript record types, as written by Claude Code into the session JSONL. +const ( + recordTypeUser = "user" + recordTypeAssistant = "assistant" + recordTypeProgress = "progress" +) + +// unknownValue stands in for a model or tool name the transcript didn't carry. +const unknownValue = "unknown" + +func findJSONLPath(sessionDir, sessionID string) (string, error) { + if sessionID != "" { + return state.RawSessionPath(sessionDir, sessionID), nil + } + + entries, err := os.ReadDir(sessionDir) + if err != nil { + return "", fmt.Errorf("read session directory: %w", err) + } + + var newest string + var newestTime time.Time + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if newest == "" || info.ModTime().After(newestTime) { + newest = filepath.Join(sessionDir, e.Name()) + newestTime = info.ModTime() + } + } + + if newest == "" { + return "", fmt.Errorf("no .jsonl files found in %s", sessionDir) + } + + return newest, nil +} + +// scanJSONL opens a JSONL file and calls fn for each successfully parsed record. +// Malformed lines are appended to warnings rather than causing an error. +// If rawLines is non-nil, each valid JSON line is appended as a json.RawMessage. +func scanJSONL(path string, warnings *[]string, fn func(*jsonlRecord), rawLines *[]json.RawMessage) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, maxScannerBuffer) + + lineNo := 0 + for scanner.Scan() { + lineNo++ + raw := bytes.TrimSpace(scanner.Bytes()) + if len(raw) == 0 { + continue + } + + var record jsonlRecord + if err := json.Unmarshal(raw, &record); err != nil { + addWarning(warnings, fmt.Sprintf("Malformed JSON at %s:%d", filepath.Base(path), lineNo)) + continue + } + + if rawLines != nil { + *rawLines = append(*rawLines, json.RawMessage(append([]byte(nil), raw...))) + } + + fn(&record) + } + + return scanner.Err() +} + +func parseJSONL(path string) (*sessionData, []json.RawMessage, error) { + data := &sessionData{ + tokenUsageByModel: make(map[string]*tokenUsage), + toolCounts: make(map[string]int), + messageCounts: make(map[string]int), + modelsSeen: make(map[string]int), + } + + var rawLines []json.RawMessage + + err := scanJSONL(path, &data.warnings, func(record *jsonlRecord) { + msgType := record.Type + switch msgType { + case recordTypeUser, recordTypeAssistant, recordTypeProgress: + data.messageCounts[msgType]++ + } + + if record.Timestamp != "" { + if data.tsMin == "" || record.Timestamp < data.tsMin { + data.tsMin = record.Timestamp + } + if data.tsMax == "" || record.Timestamp > data.tsMax { + data.tsMax = record.Timestamp + } + } + + if data.sessionID == "" && record.SessionID != "" { + data.sessionID = record.SessionID + } + if data.slug == "" && record.Slug != "" { + data.slug = record.Slug + } + if data.version == "" && record.Version != "" { + data.version = record.Version + } + if data.cwd == "" && record.Cwd != "" { + data.cwd = record.Cwd + } + if data.gitBranch == "" && record.GitBranch != "" { + data.gitBranch = record.GitBranch + } + + if msgType == recordTypeAssistant && record.Message != nil { + model := record.Message.Model + if model == "" { + model = unknownValue + } + data.modelsSeen[model]++ + + if record.Message.Usage != nil { + getOrCreateUsage(data.tokenUsageByModel, model).add(record.Message.Usage) + } + + // Content can be a string or an array of blocks; only parse arrays + var blocks []jsonlContentBlock + if json.Unmarshal(record.Message.Content, &blocks) == nil { + for _, block := range blocks { + if block.Type == "tool_use" { + name := block.Name + if name == "" { + name = unknownValue + } + data.toolCounts[name]++ + } + } + } + } + }, &rawLines) + if err != nil { + return nil, nil, fmt.Errorf("reading session file: %w", err) + } + + return data, rawLines, nil +} + +func processSubagents(sessionDir, sessionID string, warnings *[]string) ([]aicodingsession.Subagent, map[string]*tokenUsage, map[string][]json.RawMessage, error) { + subagentsDir := state.RawSubagentDir(sessionDir, sessionID) + + metaFiles, err := filepath.Glob(filepath.Join(subagentsDir, "agent-*.meta.json")) + if err != nil { + return nil, nil, nil, fmt.Errorf("glob subagent meta files: %w", err) + } + if len(metaFiles) == 0 { + return nil, nil, nil, nil + } + + var subagents []aicodingsession.Subagent + totalSubUsage := make(map[string]*tokenUsage) + rawSubagents := make(map[string][]json.RawMessage) + + for _, metaPath := range metaFiles { + base := filepath.Base(metaPath) + agentID := strings.TrimPrefix(base, "agent-") + agentID = strings.TrimSuffix(agentID, ".meta.json") + + metaData, err := os.ReadFile(metaPath) + if err != nil { + continue + } + var meta subagentMeta + if err := json.Unmarshal(metaData, &meta); err != nil { + continue + } + + var rawLines []json.RawMessage + subInput, subOutput := parseSubagentUsage( + filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID)), + totalSubUsage, + warnings, + &rawLines, + ) + + if len(rawLines) > 0 { + rawSubagents[agentID] = rawLines + } + + subagents = append(subagents, aicodingsession.Subagent{ + ID: agentID, + Type: meta.AgentType, + Description: meta.Description, + Tokens: aicodingsession.SubagentTokens{Input: subInput, Output: subOutput}, + }) + } + + return subagents, totalSubUsage, rawSubagents, nil +} + +func parseSubagentUsage(jsonlPath string, totalUsage map[string]*tokenUsage, warnings *[]string, rawLines *[]json.RawMessage) (subInput, subOutput int) { + if err := scanJSONL(jsonlPath, warnings, func(record *jsonlRecord) { + if record.Type == recordTypeAssistant && record.Message != nil && record.Message.Usage != nil { + model := record.Message.Model + if model == "" { + model = unknownValue + } + u := record.Message.Usage + subInput += u.InputTokens + subOutput += u.OutputTokens + getOrCreateUsage(totalUsage, model).add(u) + } + }, rawLines); err != nil { + addWarning(warnings, fmt.Sprintf("Failed to read subagent %s: %v", filepath.Base(jsonlPath), err)) + } + + return +} + +func mergeUsage(main, sub map[string]*tokenUsage) map[string]*tokenUsage { + if len(sub) == 0 { + return main + } + + merged := make(map[string]*tokenUsage) + for _, source := range []map[string]*tokenUsage{main, sub} { + for model, u := range source { + bucket := getOrCreateUsage(merged, model) + bucket.InputTokens += u.InputTokens + bucket.OutputTokens += u.OutputTokens + bucket.CacheCreationInputTokens += u.CacheCreationInputTokens + bucket.CacheCreation5mTokens += u.CacheCreation5mTokens + bucket.CacheCreation1hTokens += u.CacheCreation1hTokens + bucket.CacheReadInputTokens += u.CacheReadInputTokens + } + } + + return merged +} + +func computeCost(usage map[string]*tokenUsage, warnings *[]string) float64 { + totalCost := 0.0 + for model, u := range usage { + p, ok := pricing[model] + if !ok { + p = defaultPricing + addWarning(warnings, + fmt.Sprintf("Unknown model '%s', using default (sonnet) pricing", model)) + } + + cost := float64(u.InputTokens)*p.Input/1_000_000 + + float64(u.OutputTokens)*p.Output/1_000_000 + + float64(u.CacheCreation5mTokens)*p.CacheWrite/1_000_000 + + float64(u.CacheCreation1hTokens)*p.CacheWrite1h/1_000_000 + + float64(u.CacheReadInputTokens)*p.CacheRead/1_000_000 + totalCost += cost + } + + return math.Round(totalCost*10000) / 10000 +} + +func buildOutput(data *sessionData, subagents []aicodingsession.Subagent, merged map[string]*tokenUsage, warnings []string, cost float64, rawSession map[string][]json.RawMessage) *aicodingsession.Evidence { + var durationSeconds int + if data.tsMin != "" && data.tsMax != "" { + t0, err0 := time.Parse(time.RFC3339, data.tsMin) + t1, err1 := time.Parse(time.RFC3339, data.tsMax) + if err0 == nil && err1 == nil { + durationSeconds = int(t1.Sub(t0).Seconds()) + } + } + + var totalInput, totalOutput, totalCacheRead, totalCacheCreation int + for _, u := range merged { + totalInput += u.InputTokens + totalOutput += u.OutputTokens + totalCacheRead += u.CacheReadInputTokens + totalCacheCreation += u.CacheCreationInputTokens + } + + primaryModel := unknownValue + maxCount := 0 + for model, count := range data.modelsSeen { + if count > maxCount { + primaryModel = model + maxCount = count + } + } + + // Derive from merged usage to include subagent-only models + modelsUsed := make([]string, 0, len(merged)) + for model := range merged { + modelsUsed = append(modelsUsed, model) + } + sort.Strings(modelsUsed) + + toolSummary := make([]aicodingsession.ToolSummary, 0, len(data.toolCounts)) + totalInvocations := 0 + for name, count := range data.toolCounts { + toolSummary = append(toolSummary, aicodingsession.ToolSummary{ToolName: name, InvocationCount: count}) + totalInvocations += count + } + sort.Slice(toolSummary, func(i, j int) bool { + return toolSummary[i].InvocationCount > toolSummary[j].InvocationCount + }) + + return &aicodingsession.Evidence{ + Schema: aicodingsession.EvidenceSchemaURL, + ID: aicodingsession.EvidenceID, + Data: aicodingsession.Data{ + SchemaVersion: "v1", + Agent: aicodingsession.Agent{ + Name: "claude-code", + Version: data.version, + }, + Session: aicodingsession.Session{ + ID: data.sessionID, + Slug: data.slug, + StartedAt: data.tsMin, + EndedAt: data.tsMax, + DurationSeconds: durationSeconds, + }, + GitContext: &aicodingsession.GitContext{ + Branch: data.gitBranch, + WorkDir: data.cwd, + }, + Model: &aicodingsession.Model{ + Primary: primaryModel, + Provider: "anthropic", + ModelsUsed: modelsUsed, + }, + Usage: &aicodingsession.Usage{ + InputTokens: totalInput, + OutputTokens: totalOutput, + TotalTokens: totalInput + totalOutput, + CacheReadInputTokens: totalCacheRead, + CacheCreationInputTokens: totalCacheCreation, + EstimatedCostUSD: cost, + }, + ToolsUsed: &aicodingsession.ToolsUsed{ + Summary: toolSummary, + TotalInvocations: totalInvocations, + }, + Conversation: &aicodingsession.Conversation{ + TotalMessages: data.messageCounts[recordTypeUser] + data.messageCounts[recordTypeAssistant] + data.messageCounts[recordTypeProgress], + UserMessages: data.messageCounts[recordTypeUser], + AssistantMessages: data.messageCounts[recordTypeAssistant], + }, + Subagents: subagents, + RawSession: rawSession, + Warnings: warnings, + }, + } +} + +func addWarning(warnings *[]string, msg string) { + if warnings == nil { + return + } + if len(*warnings) < maxWarnings { + *warnings = append(*warnings, msg) + } else if len(*warnings) == maxWarnings { + *warnings = append(*warnings, fmt.Sprintf("... further warnings suppressed (limit %d)", maxWarnings)) + } +} + +func getOrCreateUsage(m map[string]*tokenUsage, model string) *tokenUsage { + if u, ok := m[model]; ok { + return u + } + u := &tokenUsage{} + m[model] = u + + return u +} diff --git a/app/cli/internal/trace/claude/parse_test.go b/app/cli/internal/trace/claude/parse_test.go new file mode 100644 index 000000000..bf16a97fc --- /dev/null +++ b/app/cli/internal/trace/claude/parse_test.go @@ -0,0 +1,304 @@ +// +// 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 claude + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testdataDir = "testdata" + +func TestFindJSONLPath(t *testing.T) { + t.Run("explicit session ID", func(t *testing.T) { + path, err := findJSONLPath(testdataDir, "test-session") + require.NoError(t, err) + assert.Equal(t, filepath.Join(testdataDir, "test-session.jsonl"), path) + }) + + t.Run("auto-detect picks most recent file", func(t *testing.T) { + // Explicitly set older-session.jsonl to a past mtime so the test is + // deterministic regardless of clone state (git doesn't preserve mtimes). + oldTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + require.NoError(t, os.Chtimes(filepath.Join(testdataDir, "older-session.jsonl"), oldTime, oldTime)) + + path, err := findJSONLPath(testdataDir, "") + require.NoError(t, err) + assert.Contains(t, path, "test-session.jsonl") + }) + + t.Run("explicit session ID returns path without checking existence", func(t *testing.T) { + path, err := findJSONLPath(testdataDir, "nonexistent") + require.NoError(t, err) + assert.Contains(t, path, "nonexistent.jsonl") + }) + + t.Run("missing directory", func(t *testing.T) { + _, err := findJSONLPath("/nonexistent/path", "") + assert.Error(t, err) + }) +} + +func TestParseJSONL(t *testing.T) { + data, rawLines, err := parseJSONL(filepath.Join(testdataDir, "test-session.jsonl")) + require.NoError(t, err) + + // 6 valid JSON lines out of 7 (1 malformed) + assert.Len(t, rawLines, 6) + + assert.Equal(t, "test-session", data.sessionID) + assert.Equal(t, "test-slug", data.slug) + assert.Equal(t, "2.1.81", data.version) + assert.Equal(t, "/tmp/test", data.cwd) + assert.Equal(t, "main", data.gitBranch) + + // Timestamps (min/max) + assert.Equal(t, "2026-03-24T00:00:00Z", data.tsMin) + assert.Equal(t, "2026-03-24T00:05:00Z", data.tsMax) + + // Message counts + assert.Equal(t, 2, data.messageCounts["user"]) + assert.Equal(t, 3, data.messageCounts["assistant"]) + assert.Equal(t, 1, data.messageCounts["progress"]) + + // Token usage + opusUsage, ok := data.tokenUsageByModel["claude-opus-4-6"] + require.True(t, ok) + assert.Equal(t, 300, opusUsage.InputTokens) // 100+150+50 + assert.Equal(t, 600, opusUsage.OutputTokens) // 200+300+100 + assert.Equal(t, 130, opusUsage.CacheCreationInputTokens) // 50+0+80 + // Line 2 has only the flat field (back-compat: all 50 tokens attributed to 5m). + // Line 6 has the nested cache_creation breakdown (30 -> 5m, 50 -> 1h). + assert.Equal(t, 80, opusUsage.CacheCreation5mTokens) // 50 (back-compat) + 0 + 30 (nested) + assert.Equal(t, 50, opusUsage.CacheCreation1hTokens) // 0 + 0 + 50 (nested) + assert.Equal(t, 3500, opusUsage.CacheReadInputTokens) // 1000+2000+500 + + // Tool counts + assert.Equal(t, 2, data.toolCounts["Bash"]) + assert.Equal(t, 1, data.toolCounts["Read"]) + + // Model seen counts + assert.Equal(t, 3, data.modelsSeen["claude-opus-4-6"]) + + // Malformed line warning + assert.Len(t, data.warnings, 1) + assert.Contains(t, data.warnings[0], "Malformed JSON") +} + +func TestProcessSubagents(t *testing.T) { + var warnings []string + subagents, subUsage, rawSubagents, err := processSubagents(testdataDir, "test-session", &warnings) + require.NoError(t, err) + + require.Len(t, subagents, 1) + assert.Equal(t, "abc123", subagents[0].ID) + assert.Equal(t, "Explore", subagents[0].Type) + assert.Equal(t, "Explore codebase structure", subagents[0].Description) + assert.Equal(t, 30, subagents[0].Tokens.Input) + assert.Equal(t, 50, subagents[0].Tokens.Output) + + haikuUsage, ok := subUsage["claude-haiku-4-5-20251001"] + require.True(t, ok) + assert.Equal(t, 30, haikuUsage.InputTokens) + assert.Equal(t, 50, haikuUsage.OutputTokens) + + // Raw subagent lines captured + require.Contains(t, rawSubagents, "abc123") + assert.Len(t, rawSubagents["abc123"], 2) +} + +func TestProcessSubagentsNoDir(t *testing.T) { + var warnings []string + subagents, subUsage, rawSubagents, err := processSubagents(testdataDir, "nonexistent", &warnings) + require.NoError(t, err) + assert.Nil(t, subagents) + assert.Nil(t, subUsage) + assert.Nil(t, rawSubagents) +} + +func TestMergeUsage(t *testing.T) { + main := map[string]*tokenUsage{ + "opus": {InputTokens: 100, OutputTokens: 200}, + } + sub := map[string]*tokenUsage{ + "opus": {InputTokens: 50, OutputTokens: 100}, + "haiku": {InputTokens: 30, OutputTokens: 50}, + } + + merged := mergeUsage(main, sub) + assert.Equal(t, 150, merged["opus"].InputTokens) + assert.Equal(t, 300, merged["opus"].OutputTokens) + assert.Equal(t, 30, merged["haiku"].InputTokens) + assert.Equal(t, 50, merged["haiku"].OutputTokens) +} + +func TestMergeUsageEmptySub(t *testing.T) { + main := map[string]*tokenUsage{ + "opus": {InputTokens: 100}, + } + merged := mergeUsage(main, nil) + assert.Same(t, main["opus"], merged["opus"]) +} + +func TestComputeCost(t *testing.T) { + usage := map[string]*tokenUsage{ + "claude-opus-4-6": { + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + CacheCreationInputTokens: 1_000_000, + CacheCreation5mTokens: 1_000_000, + CacheReadInputTokens: 1_000_000, + }, + } + var warnings []string + cost := computeCost(usage, &warnings) + + // $5 input + $25 output + $6.25 cache write (5m) + $0.50 cache read = $36.75 + assert.InDelta(t, 36.75, cost, 0.001) + assert.Empty(t, warnings) +} + +func TestComputeCost_SplitCacheTiers(t *testing.T) { + usage := map[string]*tokenUsage{ + "claude-opus-4-6": { + CacheCreationInputTokens: 1_000_000, + CacheCreation5mTokens: 400_000, + CacheCreation1hTokens: 600_000, + }, + } + var warnings []string + cost := computeCost(usage, &warnings) + + // 0.4M * $6.25 = $2.50 (5m tier) + // 0.6M * $10.00 = $6.00 (1h tier) + // total = $8.50 + assert.InDelta(t, 8.50, cost, 0.001) + assert.Empty(t, warnings) +} + +func TestComputeCostUnknownModel(t *testing.T) { + usage := map[string]*tokenUsage{ + "claude-unknown-99": {InputTokens: 1_000_000}, + } + var warnings []string + cost := computeCost(usage, &warnings) + + // Falls back to sonnet pricing: $3.00 per 1M input + assert.InDelta(t, 3.0, cost, 0.001) + assert.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "Unknown model") +} + +func TestParseSessionEndToEnd(t *testing.T) { + provider := New() + result, err := provider.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: testdataDir, + SessionID: "test-session", + }) + require.NoError(t, err) + + assert.Equal(t, "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", result.Schema) + assert.Equal(t, "CHAINLOOP_AI_CODING_SESSION", result.ID) + assert.Equal(t, "v1", result.Data.SchemaVersion) + assert.Equal(t, "claude-code", result.Data.Agent.Name) + assert.Equal(t, "test-session", result.Data.Session.ID) + assert.Equal(t, "test-slug", result.Data.Session.Slug) + + assert.Equal(t, 300, result.Data.Session.DurationSeconds) // 5 minutes + + // Git context + assert.Equal(t, "main", result.Data.GitContext.Branch) + assert.Equal(t, "/tmp/test", result.Data.GitContext.WorkDir) + + assert.Equal(t, "claude-opus-4-6", result.Data.Model.Primary) + assert.Contains(t, result.Data.Model.ModelsUsed, "claude-opus-4-6") + assert.Contains(t, result.Data.Model.ModelsUsed, "claude-haiku-4-5-20251001") + + // Subagents included + assert.Len(t, result.Data.Subagents, 1) + assert.Equal(t, "Explore", result.Data.Subagents[0].Type) + + // Raw session data (flat map: "main" + subagent IDs) + assert.Len(t, result.Data.RawSession["main"], 6) // 6 valid lines, 1 malformed skipped + require.Contains(t, result.Data.RawSession, "abc123") + assert.Len(t, result.Data.RawSession["abc123"], 2) + + // Warnings from malformed line + assert.Len(t, result.Data.Warnings, 1) + + // Write to temp file and verify + tmpDir := t.TempDir() + outPath := filepath.Join(tmpDir, "output.json") + f, err := os.Create(outPath) + require.NoError(t, err) + + enc := json.NewEncoder(f) + require.NoError(t, enc.Encode(result)) + require.NoError(t, f.Close()) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + assert.Contains(t, string(data), "CHAINLOOP_AI_CODING_SESSION") +} + +// TestParseSession_CraftingCompatibility verifies that the generated evidence +// can be deserialized by the upstream aicodingsession types and passes schema validation. +// This mirrors what ChainloopAICodingSessionCrafter.Craft() does when processing the file. +func TestParseSession_CraftingCompatibility(t *testing.T) { + provider := New() + result, err := provider.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: testdataDir, + SessionID: "test-session", + }) + require.NoError(t, err) + + // Serialize as the push flow does + resultBytes, err := json.Marshal(result) + require.NoError(t, err) + + // Step 1: Unmarshal into upstream envelope (same as Craft does) + var envelope struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(resultBytes, &envelope)) + require.NotEmpty(t, envelope.Data) + + // Step 2: Verify upstream struct compatibility — this catches type mismatches + // like the raw_session format issue (object vs array) that caused crafting failures. + var upstreamData aicodingsession.Data + require.NoError(t, json.Unmarshal(envelope.Data, &upstreamData), "evidence must unmarshal into upstream aicodingsession.Data") + assert.Equal(t, "claude-code", upstreamData.Agent.Name) + assert.Equal(t, "test-session", upstreamData.Session.ID) + assert.Equal(t, 300, upstreamData.Session.DurationSeconds) + + // Verify raw_session survives the round-trip as a flat map + require.Contains(t, upstreamData.RawSession, "main") + assert.NotEmpty(t, upstreamData.RawSession["main"]) + + // Verify the full evidence round-trips through upstream Evidence type + var upstreamEvidence aicodingsession.Evidence + require.NoError(t, json.Unmarshal(resultBytes, &upstreamEvidence), "evidence must unmarshal into upstream aicodingsession.Evidence") + assert.Equal(t, aicodingsession.EvidenceID, upstreamEvidence.ID) +} diff --git a/app/cli/internal/trace/claude/pid_unix.go b/app/cli/internal/trace/claude/pid_unix.go new file mode 100644 index 000000000..19f88f364 --- /dev/null +++ b/app/cli/internal/trace/claude/pid_unix.go @@ -0,0 +1,29 @@ +//go:build !windows + +// +// 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 claude + +import "syscall" + +// isPIDAlive reports whether a process with the given PID exists by sending signal 0. +func isPIDAlive(pid int) bool { + if pid <= 0 { + return false + } + + return syscall.Kill(pid, 0) == nil +} diff --git a/app/cli/internal/trace/claude/pid_windows.go b/app/cli/internal/trace/claude/pid_windows.go new file mode 100644 index 000000000..637cf70ce --- /dev/null +++ b/app/cli/internal/trace/claude/pid_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +// +// 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 claude + +import "os" + +// isPIDAlive reports whether a process with the given PID exists. +// On Windows os.FindProcess opens a handle to the process and fails if it does not exist, +// unlike on Unix where it always succeeds. +func isPIDAlive(pid int) bool { + if pid <= 0 { + return false + } + + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + defer proc.Release() + + return true +} diff --git a/app/cli/internal/trace/claude/pricing.go b/app/cli/internal/trace/claude/pricing.go new file mode 100644 index 000000000..5bfcc40e1 --- /dev/null +++ b/app/cli/internal/trace/claude/pricing.go @@ -0,0 +1,72 @@ +// +// 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 claude + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +// modelPricing holds per-million-token rates in USD for a Claude model. +type modelPricing struct { + // Input is the price per 1M base input tokens. + Input float64 `json:"input"` + // Output is the price per 1M output tokens. + Output float64 `json:"output"` + // CacheWrite is the price per 1M tokens written to the 5-minute prompt cache tier (1.25x base input). + CacheWrite float64 `json:"cache_write"` + // CacheWrite1h is the price per 1M tokens written to the 1-hour prompt cache tier (2x base input). + CacheWrite1h float64 `json:"cache_write_1h"` + // CacheRead is the price per 1M tokens read from the prompt cache (0.1x base input). + CacheRead float64 `json:"cache_read"` +} + +// pricingFile is the schema of the embedded pricing.json data file. +type pricingFile struct { + Version string `json:"version"` + DefaultModel string `json:"default_model"` + Models map[string]modelPricing `json:"models"` +} + +//go:embed pricing.json +var pricingJSON []byte + +var ( + pricing map[string]modelPricing + defaultPricing modelPricing + pricingVersion string +) + +func init() { + var f pricingFile + if err := json.Unmarshal(pricingJSON, &f); err != nil { + panic(fmt.Sprintf("claude trace: invalid embedded pricing.json: %v", err)) + } + + if len(f.Models) == 0 { + panic("claude trace: embedded pricing.json has no models") + } + + def, ok := f.Models[f.DefaultModel] + if !ok { + panic(fmt.Sprintf("claude trace: default_model %q missing from pricing.json models", f.DefaultModel)) + } + + pricing = f.Models + defaultPricing = def + pricingVersion = f.Version +} diff --git a/app/cli/internal/trace/claude/pricing.json b/app/cli/internal/trace/claude/pricing.json new file mode 100644 index 000000000..7a3f989e9 --- /dev/null +++ b/app/cli/internal/trace/claude/pricing.json @@ -0,0 +1,183 @@ +{ + "version": "2026-08-28", + "source": "https://platform.claude.com/docs/en/about-claude/pricing", + "notes": "cache_write = 5-minute tier (~1.25x base input). cache_write_1h = 1-hour tier (~2x base input). cache_read = ~0.1x base input. Multipliers are approximate; the published rates at the source URL are authoritative and may differ due to rounding (e.g. claude-3-haiku-20240307 cache_write is $0.30, not 0.25 * 1.25 = $0.3125).", + "default_model": "claude-sonnet-4-6", + "models": { + "claude-fable-5": { + "input": 10.00, + "output": 50.00, + "cache_write": 12.50, + "cache_write_1h": 20.00, + "cache_read": 1.00 + }, + "claude-mythos-5": { + "input": 10.00, + "output": 50.00, + "cache_write": 12.50, + "cache_write_1h": 20.00, + "cache_read": 1.00 + }, + "claude-opus-5": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-8": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-7": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-6": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-sonnet-5": { + "input": 2.00, + "output": 10.00, + "cache_write": 2.50, + "cache_write_1h": 4.00, + "cache_read": 0.20 + }, + "claude-sonnet-4-6": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-haiku-4-5-20251001": { + "input": 1.00, + "output": 5.00, + "cache_write": 1.25, + "cache_write_1h": 2.00, + "cache_read": 0.10 + }, + "claude-haiku-4-5": { + "input": 1.00, + "output": 5.00, + "cache_write": 1.25, + "cache_write_1h": 2.00, + "cache_read": 0.10 + }, + "claude-sonnet-4-5-20250929": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-sonnet-4-5": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-opus-4-5-20251101": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-5": { + "input": 5.00, + "output": 25.00, + "cache_write": 6.25, + "cache_write_1h": 10.00, + "cache_read": 0.50 + }, + "claude-opus-4-1-20250805": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + }, + "claude-opus-4-1": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + }, + "claude-sonnet-4-20250514": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-sonnet-4": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-opus-4-20250514": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + }, + "claude-opus-4": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + }, + "claude-3-haiku-20240307": { + "input": 0.25, + "output": 1.25, + "cache_write": 0.30, + "cache_write_1h": 0.50, + "cache_read": 0.03 + }, + "claude-3-7-sonnet-20250219": { + "input": 3.00, + "output": 15.00, + "cache_write": 3.75, + "cache_write_1h": 6.00, + "cache_read": 0.30 + }, + "claude-3-5-haiku-20241022": { + "input": 0.80, + "output": 4.00, + "cache_write": 1.00, + "cache_write_1h": 1.60, + "cache_read": 0.08 + }, + "claude-3-5-haiku": { + "input": 0.80, + "output": 4.00, + "cache_write": 1.00, + "cache_write_1h": 1.60, + "cache_read": 0.08 + }, + "claude-3-opus-20240229": { + "input": 15.00, + "output": 75.00, + "cache_write": 18.75, + "cache_write_1h": 30.00, + "cache_read": 1.50 + } + } +} diff --git a/app/cli/internal/trace/claude/pricing_test.go b/app/cli/internal/trace/claude/pricing_test.go new file mode 100644 index 000000000..e76b5cd81 --- /dev/null +++ b/app/cli/internal/trace/claude/pricing_test.go @@ -0,0 +1,52 @@ +// +// 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 claude + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPricingEmbedLoads(t *testing.T) { + require.NotEmpty(t, pricingVersion, "pricingVersion should be populated from pricing.json") + require.NotEmpty(t, pricing, "pricing map should be populated from pricing.json") + + assert.NotZero(t, defaultPricing.Input, "defaultPricing should resolve to a non-zero entry") + assert.NotZero(t, defaultPricing.Output) + + opus, ok := pricing["claude-opus-4-7"] + require.True(t, ok, "expected claude-opus-4-7 in embedded pricing table") + assert.Equal(t, 5.00, opus.Input) + assert.Equal(t, 25.00, opus.Output) + assert.Equal(t, 6.25, opus.CacheWrite) + assert.Equal(t, 10.00, opus.CacheWrite1h) + assert.Equal(t, 0.50, opus.CacheRead) + + opus5, ok := pricing["claude-opus-5"] + require.True(t, ok, "expected claude-opus-5 in embedded pricing table") + assert.Equal(t, 5.00, opus5.Input) + assert.Equal(t, 25.00, opus5.Output) + + sonnet5, ok := pricing["claude-sonnet-5"] + require.True(t, ok, "expected claude-sonnet-5 in embedded pricing table") + assert.Equal(t, 2.00, sonnet5.Input) + assert.Equal(t, 10.00, sonnet5.Output) + assert.Equal(t, 2.50, sonnet5.CacheWrite) + assert.Equal(t, 4.00, sonnet5.CacheWrite1h) + assert.Equal(t, 0.20, sonnet5.CacheRead) +} diff --git a/app/cli/internal/trace/claude/provider.go b/app/cli/internal/trace/claude/provider.go new file mode 100644 index 000000000..388952737 --- /dev/null +++ b/app/cli/internal/trace/claude/provider.go @@ -0,0 +1,206 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package claude + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// Name is the provider identifier emitted to evidence and used as the key +// in SessionRecord.Provider. Kept as an exported constant so CLI wiring +// can refer to it without instantiating the provider. +const Name = "claude-code" + +// Compile-time check that Provider implements trace.Provider. +var _ trace.Provider = (*Provider)(nil) + +// Provider implements trace.Provider for Claude Code sessions. +type Provider struct{} + +// New creates a new Claude Code provider. +func New() *Provider { + return &Provider{} +} + +// Name returns the agent identifier. +func (p *Provider) Name() string { + return Name +} + +// DiscoverSession finds the most relevant Claude Code session for the given repo root. +func (p *Provider) DiscoverSession(repoRoot string) (*trace.DiscoveredSession, error) { + session, err := discoverClaudeSession(repoRoot) + if err != nil || session == nil { + return nil, err + } + + return &trace.DiscoveredSession{ + SessionID: session.sessionID, + SessionDir: filepath.Dir(session.jsonlPath), + IsActive: session.isActive, + }, nil +} + +// SessionDirForRepo returns the Claude Code project directory for a given repo root. +func (p *Provider) SessionDirForRepo(repoRoot string) string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(homeDir, ".claude", "projects", encodeCWDForClaudePath(repoRoot)) +} + +// CopySessionData copies the Claude Code JSONL (and subagent files) from the +// Claude project directory into the store's raw/ directory so pre-push can +// parse them even if Claude rotates its own storage later. +func (p *Provider) CopySessionData(store *state.Store, repoRoot, sessionID string) error { + sourceDir := p.SessionDirForRepo(repoRoot) + if sourceDir == "" { + return nil + } + + return store.CopySessionJSONL(sessionID, sourceDir) +} + +// CaptureFileSnapshot reads the file at input.FilePath and stores its +// content under the store's snapshots/ directory so the post-tool-use +// handler can compute line-range diffs after the edit. A missing file +// (e.g. Claude's Write creating a new file) is treated as a non-error; +// other read errors (permission denied, I/O failures) are surfaced. +func (p *Provider) CaptureFileSnapshot(store *state.Store, input *trace.HookInput) error { + if input == nil || input.FilePath == "" { + return nil + } + + content, err := os.ReadFile(input.FilePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return fmt.Errorf("read %q: %w", input.FilePath, err) + } + + return store.SaveFileSnapshot(input.SessionID, input.FilePath, content) +} + +// ResolveBeforeContent returns the snapshot saved by CaptureFileSnapshot. +// The "after" argument is unused for Claude — we always rely on the pre-edit +// snapshot — but is part of the interface for providers that reconstruct +// from edits instead of snapshots. +func (p *Provider) ResolveBeforeContent(store *state.Store, input *trace.HookInput, _ []byte) []byte { + if input == nil || input.FilePath == "" { + return nil + } + + snap, err := store.LoadFileSnapshot(input.SessionID, input.FilePath) + if err != nil { + return nil + } + + return snap +} + +// CleanupAfterEdit removes the per-edit snapshot. Safe to call when no +// snapshot was ever taken. +func (p *Provider) CleanupAfterEdit(store *state.Store, input *trace.HookInput) { + if input == nil || input.FilePath == "" { + return + } + + store.DeleteFileSnapshot(input.SessionID, input.FilePath) +} + +// IsFileWritingTool returns true if the named tool modifies files on disk. +func (p *Provider) IsFileWritingTool(toolName string) bool { + return slices.Contains(fileWritingTools, toolName) +} + +// IsCommandTool returns true if the named tool runs a shell command. +func (p *Provider) IsCommandTool(toolName string) bool { + return slices.Contains(commandTools, toolName) +} + +// SystemMessage writes a message to stdout for Claude Code to display on session start. +func (p *Provider) SystemMessage(msg string) error { + if msg == "" { + return nil + } + + resp := struct { + SystemMessage string `json:"systemMessage"` + }{SystemMessage: msg} + + return json.NewEncoder(os.Stdout).Encode(resp) +} + +// ParseSession parses a Claude Code session JSONL and returns structured evidence. +func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) { + jsonlPath, err := findJSONLPath(opts.SessionDir, opts.SessionID) + if err != nil { + return nil, err + } + + data, rawMain, err := parseJSONL(jsonlPath) + if err != nil { + return nil, err + } + + // The filename carries only the sanitized ID, so prefer the caller's. + if data.sessionID == "" { + data.sessionID = opts.SessionID + } + if data.sessionID == "" { + data.sessionID = strings.TrimSuffix(filepath.Base(jsonlPath), ".jsonl") + } + + warnings := data.warnings + subagents, subUsage, rawSubagents, err := processSubagents(opts.SessionDir, data.sessionID, &warnings) + if err != nil { + return nil, err + } + + merged := mergeUsage(data.tokenUsageByModel, subUsage) + cost := computeCost(merged, &warnings) + + rawSession := map[string][]json.RawMessage{ + "main": rawMain, + } + if rawMain == nil { + rawSession["main"] = []json.RawMessage{} + } + for k, v := range rawSubagents { + if k == "main" { + warnings = append(warnings, "skipping subagent raw session with reserved key 'main'") + continue + } + rawSession[k] = v + } + + return buildOutput(data, subagents, merged, warnings, cost, rawSession), nil +} diff --git a/app/cli/internal/trace/claude/session.go b/app/cli/internal/trace/claude/session.go new file mode 100644 index 000000000..23caeaad9 --- /dev/null +++ b/app/cli/internal/trace/claude/session.go @@ -0,0 +1,103 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package claude + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" +) + +func discoverClaudeSession(repoRoot string) (*claudeSession, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, nil + } + + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + entries, err := os.ReadDir(sessionsDir) + if err != nil { + // No sessions directory means no sessions + return nil, nil + } + + var candidates []claudeSession + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + data, err := os.ReadFile(filepath.Join(sessionsDir, entry.Name())) + if err != nil { + continue + } + + var sf claudeSessionFile + if err := json.Unmarshal(data, &sf); err != nil { + continue + } + + if sf.CWD != repoRoot { + continue + } + + isActive := isPIDAlive(sf.PID) + jsonlPath := resolveJSONLPath(homeDir, sf.CWD, sf.SessionID) + + candidates = append(candidates, claudeSession{ + sessionID: sf.SessionID, + cwd: sf.CWD, + pid: sf.PID, + isActive: isActive, + jsonlPath: jsonlPath, + startedAt: sf.StartedAt, + }) + } + + if len(candidates) == 0 { + return nil, nil + } + + // Sort: active sessions first, then by StartedAt descending + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].isActive != candidates[j].isActive { + return candidates[i].isActive + } + + return candidates[i].startedAt > candidates[j].startedAt + }) + + best := candidates[0] + + return &best, nil +} + +func resolveJSONLPath(homeDir, cwd, sessionID string) string { + encodedCWD := encodeCWDForClaudePath(cwd) + + return filepath.Join(homeDir, ".claude", "projects", encodedCWD, sessionID+".jsonl") +} + +// encodeCWDForClaudePath encodes a directory path the way Claude Code does for project directories. +// Example: /Users/me/projects/myrepo -> -Users-me-projects-myrepo +// Example: /home/user/project/.claude/worktrees/name -> -home-user-project--claude-worktrees-name +func encodeCWDForClaudePath(cwd string) string { + encoded := strings.ReplaceAll(cwd, string(filepath.Separator), "-") + + return strings.ReplaceAll(encoded, ".", "-") +} diff --git a/app/cli/internal/trace/claude/session_test.go b/app/cli/internal/trace/claude/session_test.go new file mode 100644 index 000000000..dfd434598 --- /dev/null +++ b/app/cli/internal/trace/claude/session_test.go @@ -0,0 +1,180 @@ +// +// 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 claude + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeCWDForClaudePath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/Users/me/projects/myrepo", "-Users-me-projects-myrepo"}, + {"/tmp/test", "-tmp-test"}, + {"/a/b/c/d", "-a-b-c-d"}, + {"/home/user/project/.claude/worktrees/name", "-home-user-project--claude-worktrees-name"}, + {"/home/user/.config/test", "-home-user--config-test"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, encodeCWDForClaudePath(tt.input)) + } +} + +func TestDiscoverClaudeSession(t *testing.T) { + t.Run("no sessions directory", func(t *testing.T) { + session, err := discoverClaudeSession("/nonexistent/repo") + assert.NoError(t, err) + assert.Nil(t, session) + }) + + t.Run("discovers matching session", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/test-repo" + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0755)) + + sf := claudeSessionFile{ + PID: os.Getpid(), + SessionID: "test-session-id", + CWD: repoRoot, + StartedAt: 1774306212237, + } + data, err := json.Marshal(sf) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "12345.json"), data, 0600)) + + projectsDir := filepath.Join(homeDir, ".claude", "projects", encodeCWDForClaudePath(repoRoot)) + require.NoError(t, os.MkdirAll(projectsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "test-session-id.jsonl"), []byte("{}"), 0600)) + + session, err := discoverClaudeSession(repoRoot) + require.NoError(t, err) + require.NotNil(t, session) + + assert.Equal(t, "test-session-id", session.sessionID) + assert.Equal(t, repoRoot, session.cwd) + assert.True(t, session.isActive) + }) + + t.Run("ignores sessions for other directories", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0755)) + + sf := claudeSessionFile{ + PID: os.Getpid(), + SessionID: "other-session", + CWD: "/other/repo", + StartedAt: 1774306212237, + } + data, err := json.Marshal(sf) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "99999.json"), data, 0600)) + + session, err := discoverClaudeSession("/my/repo") + assert.NoError(t, err) + assert.Nil(t, session) + }) + + t.Run("prefers active over inactive sessions", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/test-repo" + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0755)) + + inactive := claudeSessionFile{ + PID: 999999999, + SessionID: "inactive-session", + CWD: repoRoot, + StartedAt: 2000000000000, + } + data, err := json.Marshal(inactive) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "999999999.json"), data, 0600)) + + active := claudeSessionFile{ + PID: os.Getpid(), + SessionID: "active-session", + CWD: repoRoot, + StartedAt: 1000000000000, + } + data, err = json.Marshal(active) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "current.json"), data, 0600)) + + session, err := discoverClaudeSession(repoRoot) + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, "active-session", session.sessionID) + assert.True(t, session.isActive) + }) +} + +func TestIsPIDAlive(t *testing.T) { + assert.True(t, isPIDAlive(os.Getpid())) + assert.False(t, isPIDAlive(999999999)) + assert.False(t, isPIDAlive(0)) + assert.False(t, isPIDAlive(-1)) +} + +func TestDiscoverSessionViaProvider(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/provider-test" + sessionsDir := filepath.Join(homeDir, ".claude", "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0755)) + + sf := claudeSessionFile{ + PID: os.Getpid(), + SessionID: "provider-session", + CWD: repoRoot, + StartedAt: 1774306212237, + } + data, err := json.Marshal(sf) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(sessionsDir, "pid.json"), data, 0600)) + + projectsDir := filepath.Join(homeDir, ".claude", "projects", encodeCWDForClaudePath(repoRoot)) + require.NoError(t, os.MkdirAll(projectsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "provider-session.jsonl"), []byte("{}"), 0600)) + + provider := New() + assert.Equal(t, "claude-code", provider.Name()) + + discovered, err := provider.DiscoverSession(repoRoot) + require.NoError(t, err) + require.NotNil(t, discovered) + + assert.Equal(t, "provider-session", discovered.SessionID) + assert.True(t, discovered.IsActive) + assert.NotEmpty(t, discovered.SessionDir) +} diff --git a/app/cli/internal/trace/claude/testdata/older-session.jsonl b/app/cli/internal/trace/claude/testdata/older-session.jsonl new file mode 100644 index 000000000..4e86e6a44 --- /dev/null +++ b/app/cli/internal/trace/claude/testdata/older-session.jsonl @@ -0,0 +1 @@ +{"type":"user","timestamp":"2025-01-01T00:00:00Z","sessionId":"older-session","message":{"role":"user","content":"old"}} diff --git a/app/cli/internal/trace/claude/testdata/test-session.jsonl b/app/cli/internal/trace/claude/testdata/test-session.jsonl new file mode 100644 index 000000000..e231d3ef4 --- /dev/null +++ b/app/cli/internal/trace/claude/testdata/test-session.jsonl @@ -0,0 +1,7 @@ +{"type":"user","timestamp":"2026-03-24T00:00:00Z","sessionId":"test-session","slug":"test-slug","version":"2.1.81","cwd":"/tmp/test","gitBranch":"main","message":{"role":"user","content":"hello"}} +{"type":"assistant","timestamp":"2026-03-24T00:01:00Z","sessionId":"test-session","slug":"test-slug","version":"2.1.81","cwd":"/tmp/test","gitBranch":"main","message":{"model":"claude-opus-4-6","content":[{"type":"text","text":"Hi there"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}],"usage":{"input_tokens":100,"output_tokens":200,"cache_creation_input_tokens":50,"cache_read_input_tokens":1000}}} +{"type":"user","timestamp":"2026-03-24T00:02:00Z","sessionId":"test-session","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"file1.txt\nfile2.txt"}]}} +{"type":"assistant","timestamp":"2026-03-24T00:03:00Z","sessionId":"test-session","message":{"model":"claude-opus-4-6","content":[{"type":"tool_use","id":"toolu_2","name":"Read","input":{"file_path":"/tmp/test/file1.txt"}},{"type":"tool_use","id":"toolu_3","name":"Bash","input":{"command":"cat file2.txt"}}],"usage":{"input_tokens":150,"output_tokens":300,"cache_creation_input_tokens":0,"cache_read_input_tokens":2000}}} +{"type":"progress","timestamp":"2026-03-24T00:03:30Z","sessionId":"test-session","data":{"type":"hook_progress"}} +{"type":"assistant","timestamp":"2026-03-24T00:05:00Z","sessionId":"test-session","message":{"model":"claude-opus-4-6","content":[{"type":"text","text":"Done!"}],"usage":{"input_tokens":50,"output_tokens":100,"cache_creation_input_tokens":80,"cache_read_input_tokens":500,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":50}}}} +this is a malformed line that should produce a warning diff --git a/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.jsonl b/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.jsonl new file mode 100644 index 000000000..53bd7b813 --- /dev/null +++ b/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.jsonl @@ -0,0 +1,2 @@ +{"type":"user","timestamp":"2026-03-24T00:01:10Z","message":{"role":"user","content":"explore"}} +{"type":"assistant","timestamp":"2026-03-24T00:01:30Z","message":{"model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"Found files"}],"usage":{"input_tokens":30,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":100}}} diff --git a/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.meta.json b/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.meta.json new file mode 100644 index 000000000..0aa9032eb --- /dev/null +++ b/app/cli/internal/trace/claude/testdata/test-session/subagents/agent-abc123.meta.json @@ -0,0 +1 @@ +{"agentType":"Explore","description":"Explore codebase structure"} \ No newline at end of file diff --git a/app/cli/internal/trace/claude/types.go b/app/cli/internal/trace/claude/types.go new file mode 100644 index 000000000..1cd09d9f0 --- /dev/null +++ b/app/cli/internal/trace/claude/types.go @@ -0,0 +1,117 @@ +// +// 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 claude + +import "encoding/json" + +type tokenUsage struct { + InputTokens int + OutputTokens int + CacheCreationInputTokens int + // CacheCreation5mTokens is the subset of CacheCreationInputTokens written to the 5-minute tier. + CacheCreation5mTokens int + // CacheCreation1hTokens is the subset of CacheCreationInputTokens written to the 1-hour tier. + CacheCreation1hTokens int + CacheReadInputTokens int +} + +func (t *tokenUsage) add(u *jsonlUsage) { + t.InputTokens += u.InputTokens + t.OutputTokens += u.OutputTokens + t.CacheCreationInputTokens += u.CacheCreationInputTokens + t.CacheReadInputTokens += u.CacheReadInputTokens + if u.CacheCreation != nil { + t.CacheCreation5mTokens += u.CacheCreation.Ephemeral5mInputTokens + t.CacheCreation1hTokens += u.CacheCreation.Ephemeral1hInputTokens + } else { + // Older records without the breakdown: attribute everything to the 5-minute tier. + t.CacheCreation5mTokens += u.CacheCreationInputTokens + } +} + +type jsonlRecord struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + SessionID string `json:"sessionId"` + Slug string `json:"slug"` + Version string `json:"version"` + Cwd string `json:"cwd"` + GitBranch string `json:"gitBranch"` + Message *jsonlMessage `json:"message,omitempty"` +} + +type jsonlMessage struct { + Model string `json:"model"` + Content json.RawMessage `json:"content"` // can be string or array + Usage *jsonlUsage `json:"usage,omitempty"` +} + +type jsonlUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + CacheCreation *jsonlCacheCreationInfo `json:"cache_creation,omitempty"` +} + +// jsonlCacheCreationInfo mirrors the nested usage.cache_creation object emitted by recent +// Claude API responses, which splits cache writes by tier (5-minute vs 1-hour). +type jsonlCacheCreationInfo struct { + Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens"` + Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens"` +} + +type jsonlContentBlock struct { + Type string `json:"type"` + Name string `json:"name"` +} + +type subagentMeta struct { + AgentType string `json:"agentType"` + Description string `json:"description"` +} + +type sessionData struct { + sessionID string + slug string + version string + cwd string + gitBranch string + tsMin string + tsMax string + tokenUsageByModel map[string]*tokenUsage + toolCounts map[string]int + messageCounts map[string]int + modelsSeen map[string]int + warnings []string +} + +// claudeSessionFile matches the JSON structure in ~/.claude/sessions/.json. +type claudeSessionFile struct { + PID int `json:"pid"` + SessionID string `json:"sessionId"` + CWD string `json:"cwd"` + StartedAt int64 `json:"startedAt"` +} + +type claudeSession struct { + sessionID string + cwd string + pid int + isActive bool + jsonlPath string + startedAt int64 +} diff --git a/app/cli/internal/trace/config/config.go b/app/cli/internal/trace/config/config.go new file mode 100644 index 000000000..06c62d97f --- /dev/null +++ b/app/cli/internal/trace/config/config.go @@ -0,0 +1,262 @@ +// +// 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 ( + "fmt" + "os" + "path/filepath" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "gopkg.in/yaml.v3" +) + +const ( + chainloopYMLFile = ".chainloop.yml" + chainloopYAMLFile = ".chainloop.yaml" +) + +// traceWorkflowName is the default workflow name used for trace attestations +// when no override is configured in .chainloop.yml. +const traceWorkflowName = "ai-coding-session" + +// ResolveWorkflowName returns the persisted workflow name when non-empty, +// otherwise falls back to the trace default. Centralizes the default so the +// CLI command and the hook handler stay in sync. +func ResolveWorkflowName(persisted string) string { + if persisted != "" { + return persisted + } + + return traceWorkflowName +} + +// ChainloopYML represents the relevant fields in .chainloop.yml. +type ChainloopYML struct { + ProjectName string `yaml:"projectName"` + ProjectVersion string `yaml:"projectVersion"` + // Organization, when set, forces trace attestations (init, add, push) + // to target that organization instead of the CLI's default. + Organization string `yaml:"organization,omitempty"` + // RequireTrace controls whether the pre-push hook blocks the push + // on attestation failure. nil is treated as false (default-off). + RequireTrace *bool `yaml:"requireTrace,omitempty"` + // WorkflowName, when set, overrides the default trace workflow name + // used when initializing attestations from trace hooks. + WorkflowName string `yaml:"workflowName,omitempty"` +} + +// FindChainloopYML looks for a .chainloop.yml (or .chainloop.yaml) file +// starting from dir and walking up to the git repository root. +// Returns the parsed config if found (with a non-empty projectName), or nil. +func FindChainloopYML(dir string) *ChainloopYML { + dir = resolveDir(dir) + repoRoot, err := tracegit.RepoRoot() + if err != nil { + return loadChainloopYMLWithProject(dir) + } + repoRoot = resolveDir(repoRoot) + + for { + if cfg := loadChainloopYMLWithProject(dir); cfg != nil { + return cfg + } + if dir == repoRoot { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return nil +} + +// LoadProjectFromYML looks for a .chainloop.yml (or .chainloop.yaml) file +// starting from dir and walking up to the git repository root. +// Returns the projectName if found, or empty string if not found. +func LoadProjectFromYML(dir string) string { + if cfg := FindChainloopYML(dir); cfg != nil { + return cfg.ProjectName + } + return "" +} + +// resolveDir returns an absolute, symlink-resolved path. +// Falls back to the absolute path if symlink resolution fails. +func resolveDir(dir string) string { + abs, err := filepath.Abs(dir) + if err != nil { + return dir + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return abs + } + return resolved +} + +// SaveProjectToYML writes (or updates) the projectName field in +// .chainloop.yml (or .chainloop.yaml) at the given directory, preserving any other fields. +func SaveProjectToYML(dir, project string) error { + return updateChainloopYMLField(dir, "projectName", project) +} + +// SaveRequireTraceToYML writes (or updates) the requireTrace field in +// .chainloop.yml at the given directory, preserving any other fields. +func SaveRequireTraceToYML(dir string, val bool) error { + return updateChainloopYMLField(dir, "requireTrace", val) +} + +// SaveOrganizationToYML writes (or updates) the organization field in +// .chainloop.yml at the given directory, preserving any other fields. +func SaveOrganizationToYML(dir, org string) error { + return updateChainloopYMLField(dir, "organization", org) +} + +// LoadOrganizationFromYML looks for .chainloop.yml starting from dir and returns +// the organization value. Returns empty string when the field is absent. +// Unlike FindChainloopYML, this does not require projectName to be set. +func LoadOrganizationFromYML(dir string) string { + cfg := findChainloopYMLAny(dir) + if cfg == nil { + return "" + } + + return cfg.Organization +} + +// SaveWorkflowToYML writes (or updates) the workflowName field in +// .chainloop.yml at the given directory, preserving any other fields. +func SaveWorkflowToYML(dir, workflow string) error { + return updateChainloopYMLField(dir, "workflowName", workflow) +} + +// LoadWorkflowFromYML looks for .chainloop.yml starting from dir and returns +// the workflowName value. Returns empty string when the field is absent. +// Unlike FindChainloopYML, this does not require projectName to be set. +func LoadWorkflowFromYML(dir string) string { + cfg := findChainloopYMLAny(dir) + if cfg == nil { + return "" + } + + return cfg.WorkflowName +} + +// updateChainloopYMLField reads, updates a single field, and writes back +// the .chainloop.yml file, preserving all other fields. +func updateChainloopYMLField(dir, key string, value any) error { + path := resolveChainloopYMLPath(dir) + + existing := make(map[string]any) + if data, err := os.ReadFile(path); err == nil { + if err := yaml.Unmarshal(data, &existing); err != nil { + return fmt.Errorf("parse %s: %w", filepath.Base(path), err) + } + } + + existing[key] = value + + out, err := yaml.Marshal(existing) + if err != nil { + return fmt.Errorf("marshal %s: %w", filepath.Base(path), err) + } + + return os.WriteFile(path, out, 0600) +} + +// LoadRequireTraceFromYML looks for .chainloop.yml starting from dir +// and returns the requireTrace value. Returns false when the field is +// absent or nil (default-off). +// Unlike FindChainloopYML, this does not require projectName to be set. +func LoadRequireTraceFromYML(dir string) bool { + cfg := findChainloopYMLAny(dir) + if cfg == nil || cfg.RequireTrace == nil { + return false + } + + return *cfg.RequireTrace +} + +// findChainloopYMLAny looks for a .chainloop.yml (or .chainloop.yaml) file +// starting from dir and walking up to the git repository root. +// Unlike FindChainloopYML, it returns any parseable config regardless of +// whether projectName is set. +func findChainloopYMLAny(dir string) *ChainloopYML { + dir = resolveDir(dir) + repoRoot, err := tracegit.RepoRoot() + if err != nil { + return loadChainloopYMLFromDir(dir) + } + repoRoot = resolveDir(repoRoot) + + for { + if cfg := loadChainloopYMLFromDir(dir); cfg != nil { + return cfg + } + if dir == repoRoot { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return nil +} + +// resolveChainloopYMLPath returns the path to the existing .chainloop.yml +// (or .chainloop.yaml) in dir. If neither exists, defaults to .chainloop.yml. +func resolveChainloopYMLPath(dir string) string { + for _, name := range []string{chainloopYMLFile, chainloopYAMLFile} { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p + } + } + return filepath.Join(dir, chainloopYMLFile) +} + +// loadChainloopYMLFromDir loads the ChainloopYML struct from the given directory, or nil if not found. +func loadChainloopYMLFromDir(dir string) *ChainloopYML { + for _, name := range []string{chainloopYMLFile, chainloopYAMLFile} { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + var cfg ChainloopYML + if err := yaml.Unmarshal(data, &cfg); err != nil { + continue + } + return &cfg + } + return nil +} + +// loadChainloopYMLWithProject is like loadChainloopYMLFromDir but only returns +// a config that has a non-empty projectName. This ensures directory walking +// skips empty or incomplete .chainloop.yml files. +func loadChainloopYMLWithProject(dir string) *ChainloopYML { + cfg := loadChainloopYMLFromDir(dir) + if cfg != nil && cfg.ProjectName != "" { + return cfg + } + return nil +} diff --git a/app/cli/internal/trace/config/config_test.go b/app/cli/internal/trace/config/config_test.go new file mode 100644 index 000000000..735d55e65 --- /dev/null +++ b/app/cli/internal/trace/config/config_test.go @@ -0,0 +1,344 @@ +// +// 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" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initTempGitRepo creates a temp dir and runs git init in it. +func initTempGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + initGitRepo(t, dir) + return dir +} + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + cmd := exec.Command("git", "init", dir) + require.NoError(t, cmd.Run()) +} + +func TestLoadProjectFromYML(t *testing.T) { + t.Run("reads projectName from .chainloop.yml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: my-project\n"), 0600)) + assert.Equal(t, "my-project", LoadProjectFromYML(dir)) + }) + + t.Run("reads projectName from .chainloop.yaml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yaml"), []byte("projectName: yaml-project\n"), 0600)) + assert.Equal(t, "yaml-project", LoadProjectFromYML(dir)) + }) + + t.Run("prefers .chainloop.yml over .chainloop.yaml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: from-yml\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yaml"), []byte("projectName: from-yaml\n"), 0600)) + assert.Equal(t, "from-yml", LoadProjectFromYML(dir)) + }) + + t.Run("walks up to git repo root", func(t *testing.T) { + repoDir := initTempGitRepo(t) + child := filepath.Join(repoDir, "subdir") + require.NoError(t, os.Mkdir(child, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: parent-project\n"), 0600)) + + // Run from inside the child dir so git rev-parse finds our repo + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(child)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + assert.Equal(t, "parent-project", LoadProjectFromYML(child)) + }) + + t.Run("does not walk above git repo root", func(t *testing.T) { + parent := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(parent, ".chainloop.yml"), []byte("projectName: outside-repo\n"), 0600)) + + repoDir := filepath.Join(parent, "repo") + require.NoError(t, os.Mkdir(repoDir, 0755)) + initGitRepo(t, repoDir) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + assert.Empty(t, LoadProjectFromYML(repoDir)) + }) + + t.Run("returns empty when no file found", func(t *testing.T) { + dir := t.TempDir() + assert.Empty(t, LoadProjectFromYML(dir)) + }) + + t.Run("skips file without projectName", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("otherField: value\n"), 0600)) + assert.Empty(t, LoadProjectFromYML(dir)) + }) +} + +func TestSaveProjectToYML(t *testing.T) { + t.Run("creates new file", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, SaveProjectToYML(dir, "new-project")) + + got := LoadProjectFromYML(dir) + assert.Equal(t, "new-project", got) + }) + + t.Run("preserves existing fields", func(t *testing.T) { + dir := t.TempDir() + existing := "projectName: old-project\nprojectVersion: v1.0.0\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte(existing), 0600)) + + require.NoError(t, SaveProjectToYML(dir, "updated-project")) + + data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yml")) + require.NoError(t, err) + assert.Contains(t, string(data), "projectName: updated-project") + assert.Contains(t, string(data), "projectVersion: v1.0.0") + }) + + t.Run("updates existing projectName", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: old\n"), 0600)) + + require.NoError(t, SaveProjectToYML(dir, "new")) + assert.Equal(t, "new", LoadProjectFromYML(dir)) + }) + + t.Run("respects existing .chainloop.yaml extension", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yaml"), []byte("projectName: yaml-ext\nprojectVersion: v2.0.0\n"), 0600)) + + require.NoError(t, SaveProjectToYML(dir, "updated")) + + // Should have updated the .yaml file, not created a new .yml file + _, err := os.Stat(filepath.Join(dir, ".chainloop.yml")) + assert.True(t, os.IsNotExist(err), "should not create .yml when .yaml exists") + + data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yaml")) + require.NoError(t, err) + assert.Contains(t, string(data), "projectName: updated") + assert.Contains(t, string(data), "projectVersion: v2.0.0") + }) + + t.Run("returns error on malformed yaml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte(":\ninvalid: [yaml\n"), 0600)) + + err := SaveProjectToYML(dir, "proj") + assert.Error(t, err) + }) +} + +func TestRequireTrace(t *testing.T) { + t.Run("defaults to false when field missing", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\n"), 0600)) + assert.False(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("defaults to false when no config file", func(t *testing.T) { + dir := t.TempDir() + assert.False(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("returns false when explicitly set", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\nrequireTrace: false\n"), 0600)) + assert.False(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("reads from file without projectName", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("requireTrace: false\n"), 0600)) + assert.False(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("returns true when explicitly set", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\nrequireTrace: true\n"), 0600)) + assert.True(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("save and load round-trip", 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, SaveRequireTraceToYML(dir, false)) + assert.False(t, LoadRequireTraceFromYML(dir)) + + require.NoError(t, SaveRequireTraceToYML(dir, true)) + assert.True(t, LoadRequireTraceFromYML(dir)) + }) + + t.Run("preserves other fields", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: my-proj\nprojectVersion: v1.0.0\n"), 0600)) + + require.NoError(t, SaveRequireTraceToYML(dir, false)) + + data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yml")) + require.NoError(t, err) + assert.Contains(t, string(data), "projectName: my-proj") + assert.Contains(t, string(data), "projectVersion: v1.0.0") + assert.Contains(t, string(data), "requireTrace: false") + }) +} + +func TestWorkflow(t *testing.T) { + t.Run("defaults to empty when field missing", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\n"), 0600)) + assert.Empty(t, LoadWorkflowFromYML(dir)) + }) + + t.Run("defaults to empty when no config file", func(t *testing.T) { + dir := t.TempDir() + assert.Empty(t, LoadWorkflowFromYML(dir)) + }) + + t.Run("reads from file without projectName", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("workflowName: my-flow\n"), 0600)) + assert.Equal(t, "my-flow", LoadWorkflowFromYML(dir)) + }) + + t.Run("save and load round-trip", 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, SaveWorkflowToYML(dir, "my-flow")) + assert.Equal(t, "my-flow", LoadWorkflowFromYML(dir)) + + require.NoError(t, SaveWorkflowToYML(dir, "another")) + assert.Equal(t, "another", LoadWorkflowFromYML(dir)) + }) + + t.Run("preserves other fields", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: my-proj\nprojectVersion: v1.0.0\nrequireTrace: true\n"), 0600)) + + require.NoError(t, SaveWorkflowToYML(dir, "my-flow")) + + data, err := os.ReadFile(filepath.Join(dir, ".chainloop.yml")) + require.NoError(t, err) + assert.Contains(t, string(data), "projectName: my-proj") + assert.Contains(t, string(data), "projectVersion: v1.0.0") + assert.Contains(t, string(data), "requireTrace: true") + assert.Contains(t, string(data), "workflowName: my-flow") + }) +} + +func TestFindChainloopYML(t *testing.T) { + t.Run("reads both fields from .chainloop.yml", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: my-project\nprojectVersion: v1.2.3\n"), 0600)) + cfg := FindChainloopYML(dir) + require.NotNil(t, cfg) + assert.Equal(t, "my-project", cfg.ProjectName) + assert.Equal(t, "v1.2.3", cfg.ProjectVersion) + }) + + t.Run("returns nil when no file found", func(t *testing.T) { + dir := t.TempDir() + assert.Nil(t, FindChainloopYML(dir)) + }) + + t.Run("returns empty version when not set", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("projectName: my-project\n"), 0600)) + cfg := FindChainloopYML(dir) + require.NotNil(t, cfg) + assert.Equal(t, "my-project", cfg.ProjectName) + assert.Empty(t, cfg.ProjectVersion) + }) + + t.Run("walks up to git repo root", func(t *testing.T) { + repoDir := initTempGitRepo(t) + child := filepath.Join(repoDir, "subdir") + require.NoError(t, os.Mkdir(child, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: p\nprojectVersion: v2.0.0\n"), 0600)) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(child)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + cfg := FindChainloopYML(child) + require.NotNil(t, cfg) + assert.Equal(t, "p", cfg.ProjectName) + assert.Equal(t, "v2.0.0", cfg.ProjectVersion) + }) + + t.Run("reads requireTrace field", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\nrequireTrace: false\n"), 0600)) + cfg := FindChainloopYML(dir) + require.NotNil(t, cfg) + require.NotNil(t, cfg.RequireTrace) + assert.False(t, *cfg.RequireTrace) + }) + + t.Run("requireTrace nil when not set", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), + []byte("projectName: p\n"), 0600)) + cfg := FindChainloopYML(dir) + require.NotNil(t, cfg) + assert.Nil(t, cfg.RequireTrace) + }) + + t.Run("skips file without projectName and walks up", func(t *testing.T) { + repoDir := initTempGitRepo(t) + child := filepath.Join(repoDir, "subdir") + require.NoError(t, os.Mkdir(child, 0755)) + // Child has a .chainloop.yml without projectName + require.NoError(t, os.WriteFile(filepath.Join(child, ".chainloop.yml"), []byte("otherField: value\n"), 0600)) + // Parent has the real config + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: parent\nprojectVersion: v3.0.0\n"), 0600)) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(child)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + cfg := FindChainloopYML(child) + require.NotNil(t, cfg) + assert.Equal(t, "parent", cfg.ProjectName) + assert.Equal(t, "v3.0.0", cfg.ProjectVersion) + }) +} diff --git a/app/cli/internal/trace/cursor/hooks.go b/app/cli/internal/trace/cursor/hooks.go new file mode 100644 index 000000000..662ac4a8e --- /dev/null +++ b/app/cli/internal/trace/cursor/hooks.go @@ -0,0 +1,330 @@ +// +// 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 cursor + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +const ( + // hookMarker identifies Chainloop-installed entries inside .cursor/hooks.json. + hookMarker = "chainloop trace hook cursor" + // settingsFile is the project-local Cursor hooks configuration file. + settingsFile = ".cursor/hooks.json" + // hooksSchemaVersion is the schema version Cursor expects in hooks.json. + hooksSchemaVersion = 1 + // defaultHookTimeoutSeconds is the per-hook timeout persisted in hooks.json. + defaultHookTimeoutSeconds = 30 + + // Cursor hook event names. + eventSessionStart = "sessionStart" + eventSessionEnd = "sessionEnd" + eventAfterFileEdit = "afterFileEdit" + + // syntheticEditToolName is the synthetic tool name set on HookInput for + // afterFileEdit events so generic handlers can treat them like + // Claude-style file-writing tool invocations. + syntheticEditToolName = "edit" +) + +type cursorHookEvent struct { + event string + command string +} + +// cursorHookEvents is the full list of hooks installed by InstallHooks. +var cursorHookEvents = []cursorHookEvent{ + {eventSessionStart, "chainloop trace hook cursor session-start"}, + {eventSessionEnd, "chainloop trace hook cursor session-end"}, + {eventAfterFileEdit, "chainloop trace hook cursor after-file-edit"}, +} + +// SettingsFile returns the absolute path to .cursor/hooks.json for the given repo. +func (p *Provider) SettingsFile(repoRoot string) string { + return filepath.Join(repoRoot, settingsFile) +} + +// InstallHooks adds Chainloop hooks to .cursor/hooks.json. +func (p *Provider) InstallHooks(repoRoot string) error { + return p.installHookEvents(repoRoot, cursorHookEvents) +} + +// InstallHooksForTraceRun installs every hook except SessionEnd; trace +// run drives end-of-session itself. +func (p *Provider) InstallHooksForTraceRun(repoRoot string) error { + events := make([]cursorHookEvent, 0, len(cursorHookEvents)) + for _, h := range cursorHookEvents { + if h.event == eventSessionEnd { + continue + } + events = append(events, h) + } + + return p.installHookEvents(repoRoot, events) +} + +func (p *Provider) installHookEvents(repoRoot string, events []cursorHookEvent) error { + settingsPath := filepath.Join(repoRoot, settingsFile) + + settings, err := readJSONFile(settingsPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + hooks = make(map[string]any) + } + + changed := false + for _, h := range events { + if addHookEntry(hooks, h.event, h.command) { + changed = true + } + } + + if version, _ := settings["version"].(float64); int(version) != hooksSchemaVersion { + settings["version"] = hooksSchemaVersion + changed = true + } + + if !changed { + return nil + } + + settings["hooks"] = hooks + + return writeJSONFile(settingsPath, settings) +} + +// UninstallHooks removes Chainloop hooks from .cursor/hooks.json. +// Leaves user-authored hooks intact. +func (p *Provider) UninstallHooks(repoRoot string) error { + settingsPath := filepath.Join(repoRoot, settingsFile) + + settings, err := readJSONFile(settingsPath) + if err != nil { + return err + } + + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + return nil + } + + changed := false + for _, h := range cursorHookEvents { + if removeHookEntry(hooks, h.event) { + changed = true + } + } + + if !changed { + return nil + } + + if len(hooks) == 0 { + delete(settings, "hooks") + // version is meaningless without any hooks; drop it too + delete(settings, "version") + } else { + settings["hooks"] = hooks + } + + if len(settings) == 0 { + err := os.Remove(settingsPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err + } + + return writeJSONFile(settingsPath, settings) +} + +// cursorHookInput is the wire-level structure of a Cursor hook JSON payload. +// Only the fields we consume are modelled; unknown fields are ignored. +type cursorHookInput struct { + ConversationID string `json:"conversation_id"` + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name"` + CursorVersion string `json:"cursor_version"` + Model string `json:"model"` + FilePath string `json:"file_path"` + Edits []cursorHookEdit `json:"edits"` +} + +type cursorHookEdit struct { + OldString string `json:"old_string"` + NewString string `json:"new_string"` +} + +// maxHookPayloadBytes caps hook payload reads to defend against runaway or +// malformed payloads (afterFileEdit edits can embed arbitrary file content). +const maxHookPayloadBytes = 16 * 1024 * 1024 + +// ReadHookInput parses a Cursor hook JSON payload from the given reader. +// ConversationID is preferred as the session identifier; session_id is used +// as a fallback for forward-compatibility. +func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { + data, err := io.ReadAll(io.LimitReader(r, maxHookPayloadBytes)) + if err != nil { + return nil, err + } + + var raw cursorHookInput + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + sessionID := raw.ConversationID + if sessionID == "" { + sessionID = raw.SessionID + } + + input := &trace.HookInput{ + SessionID: sessionID, + HookEventName: raw.HookEventName, + FilePath: raw.FilePath, + AgentVersion: raw.CursorVersion, + Model: raw.Model, + } + + if raw.HookEventName == eventAfterFileEdit { + input.ToolName = syntheticEditToolName + } + + if len(raw.Edits) > 0 { + input.Edits = make([]trace.HookEdit, len(raw.Edits)) + for i, e := range raw.Edits { + input.Edits[i] = trace.HookEdit{OldString: e.OldString, NewString: e.NewString} + } + } + + return input, nil +} + +func addHookEntry(hooks map[string]any, event, command string) bool { + entries, _ := hooks[event].([]any) + + for i, entry := range entries { + if !entryContainsChainloopHook(entry) { + continue + } + m, _ := entry.(map[string]any) + existingCmd, _ := m["command"].(string) + if existingCmd == command { + return false + } + entries[i] = buildHookEntry(command) + hooks[event] = entries + + return true + } + + hooks[event] = append(entries, buildHookEntry(command)) + + return true +} + +func removeHookEntry(hooks map[string]any, event string) bool { + entries, _ := hooks[event].([]any) + if len(entries) == 0 { + return false + } + + var kept []any + for _, entry := range entries { + if !entryContainsChainloopHook(entry) { + kept = append(kept, entry) + } + } + + if len(kept) == len(entries) { + return false + } + + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept + } + + return true +} + +func buildHookEntry(command string) map[string]any { + return map[string]any{ + "command": command, + "timeout": defaultHookTimeoutSeconds, + } +} + +func entryContainsChainloopHook(entry any) bool { + m, ok := entry.(map[string]any) + if !ok { + return false + } + + cmd, _ := m["command"].(string) + + return strings.Contains(cmd, hookMarker) +} + +func readJSONFile(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return make(map[string]any), nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + if result == nil { + result = make(map[string]any) + } + + return result, nil +} + +func writeJSONFile(path string, data map[string]any) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal JSON: %w", err) + } + + content = append(content, '\n') + + return os.WriteFile(path, content, 0600) +} diff --git a/app/cli/internal/trace/cursor/hooks_test.go b/app/cli/internal/trace/cursor/hooks_test.go new file mode 100644 index 000000000..1ef225319 --- /dev/null +++ b/app/cli/internal/trace/cursor/hooks_test.go @@ -0,0 +1,209 @@ +// +// 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 cursor + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallHooksCreatesFile(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooks(repoRoot)) + + settingsPath := filepath.Join(repoRoot, settingsFile) + data, err := os.ReadFile(settingsPath) + require.NoError(t, err, "read %s", settingsPath) + + var out map[string]any + require.NoError(t, json.Unmarshal(data, &out), "parse settings json") + + v, _ := out["version"].(float64) + require.Equal(t, hooksSchemaVersion, int(v), "settings version") + + hooks, _ := out["hooks"].(map[string]any) + for _, evt := range []string{eventSessionStart, eventSessionEnd, eventAfterFileEdit} { + entries, _ := hooks[evt].([]any) + require.NotEmpty(t, entries, "no hook entries for event %q", evt) + } +} + +func TestInstallHooksPreservesExistingUserHooks(t *testing.T) { + repoRoot := t.TempDir() + settingsPath := filepath.Join(repoRoot, settingsFile) + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0755)) + + existing := map[string]any{ + "version": 1, + "hooks": map[string]any{ + eventSessionStart: []any{ + map[string]any{"command": "./my-hook.sh", "timeout": 10}, + }, + }, + } + data, err := json.MarshalIndent(existing, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(settingsPath, data, 0600)) + + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + out, err := os.ReadFile(settingsPath) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(out, &parsed)) + + hooks, ok := parsed["hooks"].(map[string]any) + require.True(t, ok, "hooks is not an object") + entries, ok := hooks[eventSessionStart].([]any) + require.True(t, ok, "%s entries missing", eventSessionStart) + require.Len(t, entries, 2, "expected 2 entries (user + chainloop)") + + var userCount, chainloopCount int + for _, e := range entries { + m, ok := e.(map[string]any) + require.True(t, ok, "entry is not an object") + if cmd, _ := m["command"].(string); cmd == "./my-hook.sh" { + userCount++ + } + if entryContainsChainloopHook(e) { + chainloopCount++ + } + } + assert.Equal(t, 1, userCount, "user hook missing") + assert.Equal(t, 1, chainloopCount, "chainloop hook missing") +} + +func TestUninstallHooksLeavesUserHooks(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + // Add a user hook alongside chainloop's + settingsPath := filepath.Join(repoRoot, settingsFile) + data, err := os.ReadFile(settingsPath) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(data, &parsed)) + hooks, ok := parsed["hooks"].(map[string]any) + require.True(t, ok, "hooks is not an object") + hooks[eventSessionStart] = append(hooks[eventSessionStart].([]any), + map[string]any{"command": "./user.sh", "timeout": 5}) + out, err := json.MarshalIndent(parsed, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(settingsPath, out, 0600)) + + require.NoError(t, p.UninstallHooks(repoRoot)) + + raw, err := os.ReadFile(settingsPath) + require.NoError(t, err, "settings file should still exist with user hook") + var after map[string]any + require.NoError(t, json.Unmarshal(raw, &after)) + afterHooks, ok := after["hooks"].(map[string]any) + require.True(t, ok, "hooks is not an object") + entries, ok := afterHooks[eventSessionStart].([]any) + require.True(t, ok, "%s entries missing", eventSessionStart) + require.Len(t, entries, 1, "expected 1 entry (user only)") + m, ok := entries[0].(map[string]any) + require.True(t, ok, "entry is not an object") + cmd, _ := m["command"].(string) + assert.Equal(t, "./user.sh", cmd, "user entry lost, got %+v", m) +} + +func TestUninstallHooksDeletesFileWhenEmpty(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + require.NoError(t, p.UninstallHooks(repoRoot)) + + settingsPath := filepath.Join(repoRoot, settingsFile) + _, err := os.Stat(settingsPath) + assert.True(t, os.IsNotExist(err), "expected %s to be removed, stat err: %v", settingsPath, err) +} + +func TestReadHookInputCapturesCursorVersion(t *testing.T) { + payload := []byte(`{ + "conversation_id": "conv-ver", + "hook_event_name": "sessionStart", + "cursor_version": "3.2.16", + "model": "gpt-5" + }`) + + p := New() + in, err := p.ReadHookInput(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, "3.2.16", in.AgentVersion, "AgentVersion") + assert.Equal(t, "gpt-5", in.Model, "Model") +} + +func TestReadHookInputAfterFileEdit(t *testing.T) { + payload := []byte(`{ + "conversation_id": "conv-abc", + "generation_id": "gen-1", + "hook_event_name": "afterFileEdit", + "model": "claude-4", + "cursor_version": "0.48.0", + "file_path": "/abs/path/file.go", + "edits": [ + {"old_string": "foo", "new_string": "bar"}, + {"old_string": "baz", "new_string": "qux"} + ] + }`) + + p := New() + in, err := p.ReadHookInput(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, "conv-abc", in.SessionID, "SessionID") + assert.Equal(t, "afterFileEdit", in.HookEventName, "HookEventName") + assert.Equal(t, syntheticEditToolName, in.ToolName, "ToolName") + assert.Equal(t, "/abs/path/file.go", in.FilePath, "FilePath") + require.Len(t, in.Edits, 2, "Edits len") + assert.Equal(t, "foo", in.Edits[0].OldString, "first edit OldString") + assert.Equal(t, "bar", in.Edits[0].NewString, "first edit NewString") +} + +func TestReadHookInputSessionStart(t *testing.T) { + payload := []byte(`{ + "conversation_id": "conv-xyz", + "hook_event_name": "sessionStart", + "cursor_version": "0.48.0" + }`) + + p := New() + in, err := p.ReadHookInput(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, "conv-xyz", in.SessionID, "SessionID") + assert.Empty(t, in.ToolName, "ToolName should be empty on sessionStart") + assert.Empty(t, in.Edits, "Edits should be empty on sessionStart") +} + +func TestReadHookInputFallsBackToSessionID(t *testing.T) { + payload := []byte(`{ + "session_id": "fallback-session" + }`) + p := New() + in, err := p.ReadHookInput(bytes.NewReader(payload)) + require.NoError(t, err) + assert.Equal(t, "fallback-session", in.SessionID, "expected fallback to session_id") +} diff --git a/app/cli/internal/trace/cursor/parse.go b/app/cli/internal/trace/cursor/parse.go new file mode 100644 index 000000000..5d83ad13e --- /dev/null +++ b/app/cli/internal/trace/cursor/parse.go @@ -0,0 +1,95 @@ +// +// 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 cursor + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" +) + +// parsedTranscript collects the per-session signals we extract from a +// Cursor JSONL transcript. Token usage stays empty because Cursor does not +// expose it in the transcript format. +type parsedTranscript struct { + // UserMessages is the count of records with role "user". + UserMessages int + // AssistantMessages is the count of records with role "assistant". + AssistantMessages int + // ToolCounts maps tool name to invocation count, derived from + // "tool_use" content blocks across assistant messages. + ToolCounts map[string]int + // RawRecords retains every JSONL line for evidence storage. + RawRecords []json.RawMessage +} + +// parseCursorJSONL reads a Cursor agent transcript JSONL file and aggregates +// message and tool-use counts. +func parseCursorJSONL(path string) (*parsedTranscript, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + // Transcripts can contain long lines (file contents embedded in a prompt, etc.). + // Match Claude parser's generous buffer to avoid tokenizer errors on wide records. + const maxLineBytes = 10 * 1024 * 1024 + scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes) + + out := &parsedTranscript{ToolCounts: map[string]int{}} + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var rec cursorJSONLRecord + if err := json.Unmarshal(line, &rec); err != nil { + // Malformed line (e.g. a truncated final record while Cursor is + // still writing). Skip it: storing invalid JSON as a RawMessage + // would fail the evidence encoder and abort the attestation. + continue + } + + out.RawRecords = append(out.RawRecords, json.RawMessage(bytes.Clone(line))) + + switch rec.Role { + case "user": + out.UserMessages++ + case "assistant": + out.AssistantMessages++ + } + + for _, block := range rec.Message.Content { + if block.Type == "tool_use" && block.Name != "" { + out.ToolCounts[block.Name]++ + } + } + } + + if scanErr := scanner.Err(); scanErr != nil && !errors.Is(scanErr, io.EOF) { + return out, fmt.Errorf("scan cursor transcript: %w", scanErr) + } + + return out, nil +} diff --git a/app/cli/internal/trace/cursor/parse_test.go b/app/cli/internal/trace/cursor/parse_test.go new file mode 100644 index 000000000..4e06094c6 --- /dev/null +++ b/app/cli/internal/trace/cursor/parse_test.go @@ -0,0 +1,179 @@ +// +// 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 cursor + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sampleJSONL = `{"role":"user","message":{"content":[{"type":"text","text":"hello"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"hi"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"anything else?"}]}} +` + +func TestParseSessionMessageCounts(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "session-1.jsonl") + require.NoError(t, os.WriteFile(path, []byte(sampleJSONL), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "session-1", + }) + require.NoError(t, err) + + assert.Equal(t, 1, result.Data.Conversation.UserMessages, "UserMessages") + assert.Equal(t, 2, result.Data.Conversation.AssistantMessages, "AssistantMessages") + assert.Equal(t, agentName, result.Data.Agent.Name, "Agent.Name") + assert.Equal(t, "session-1", result.Data.Session.ID, "Session.ID") + assert.NotEmpty(t, result.Data.Warnings, "expected at least one warning about missing token usage") +} + +func TestParseSessionMissingFile(t *testing.T) { + p := New() + _, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: t.TempDir(), + SessionID: "does-not-exist", + }) + require.Error(t, err, "expected error for missing transcript") +} + +// toolUseJSONL mirrors the shape observed in real Cursor transcripts: +// assistant messages can mix text blocks with tool_use blocks, and the +// same tool name can appear multiple times across records. +const toolUseJSONL = `{"role":"user","message":{"content":[{"type":"text","text":"do work"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"reading"},{"type":"tool_use","name":"ReadFile","input":{"path":"/x"}},{"type":"tool_use","name":"ReadFile","input":{"path":"/y"}}]}} +{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ApplyPatch","input":"*** Begin Patch ***"}]}} +{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Grep","input":{"pattern":"foo"}},{"type":"tool_use","name":"ReadFile","input":{"path":"/z"}}]}} +` + +func TestParseSessionPropagatesAgentVersion(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "ver-1.jsonl") + require.NoError(t, os.WriteFile(path, []byte(sampleJSONL), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "ver-1", + AgentVersion: "3.2.16", + Model: "gpt-5", + }) + require.NoError(t, err) + + assert.Equal(t, "3.2.16", result.Data.Agent.Version, "Agent.Version") + require.NotNil(t, result.Data.Model, "Model") + assert.Equal(t, "gpt-5", result.Data.Model.Primary, "Model.Primary") +} + +func TestParseSessionExtractsToolUsage(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "tools-1.jsonl") + require.NoError(t, os.WriteFile(path, []byte(toolUseJSONL), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "tools-1", + }) + require.NoError(t, err) + + tools := result.Data.ToolsUsed + require.NotNil(t, tools, "ToolsUsed is nil; expected populated summary") + assert.Equal(t, 5, tools.TotalInvocations, "TotalInvocations") + + got := map[string]int{} + for _, s := range tools.Summary { + got[s.ToolName] = s.InvocationCount + } + want := map[string]int{"ReadFile": 3, "ApplyPatch": 1, "Grep": 1} + for name, count := range want { + assert.Equal(t, count, got[name], "tool %q", name) + } + + // Highest-count tool must come first (sorted desc by count). + require.NotEmpty(t, tools.Summary) + assert.Equal(t, "ReadFile", tools.Summary[0].ToolName, "expected ReadFile first in summary") +} + +func TestCopySessionDataFromFlat(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/myrepo" + sanitized := sanitizeRepoPath(repoRoot) + src := filepath.Join(homeDir, ".cursor", "projects", sanitized, "agent-transcripts") + require.NoError(t, os.MkdirAll(src, 0755)) + srcFile := filepath.Join(src, "conv-1.jsonl") + require.NoError(t, os.WriteFile(srcFile, []byte(sampleJSONL), 0600)) + + gitDir := filepath.Join(t.TempDir(), ".git") + require.NoError(t, os.MkdirAll(gitDir, 0755)) + + p := New() + require.NoError(t, p.CopySessionData(state.NewGitStore(gitDir), repoRoot, "conv-1")) + + dst := filepath.Join(gitDir, "chainloop-trace", "raw", "conv-1.jsonl") + got, err := os.ReadFile(dst) + require.NoError(t, err, "read destination") + require.Equal(t, sampleJSONL, string(got), "destination content mismatch") +} + +func TestCopySessionDataFromNested(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := "/tmp/myrepo" + sanitized := sanitizeRepoPath(repoRoot) + nested := filepath.Join(homeDir, ".cursor", "projects", sanitized, "agent-transcripts", "conv-2") + require.NoError(t, os.MkdirAll(nested, 0755)) + srcFile := filepath.Join(nested, "conv-2.jsonl") + require.NoError(t, os.WriteFile(srcFile, []byte(sampleJSONL), 0600)) + + gitDir := filepath.Join(t.TempDir(), ".git") + require.NoError(t, os.MkdirAll(gitDir, 0755)) + + p := New() + require.NoError(t, p.CopySessionData(state.NewGitStore(gitDir), repoRoot, "conv-2")) + + dst := filepath.Join(gitDir, "chainloop-trace", "raw", "conv-2.jsonl") + _, err := os.Stat(dst) + require.NoError(t, err, "destination missing") +} + +func TestParseCursorJSONLSkipsMalformedLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "session-1.jsonl") + // Trailing line is truncated, as happens when Cursor is still writing. + content := sampleJSONL + " \n" + `{"role":"assistant","message":{"con` + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + + parsed, err := parseCursorJSONL(path) + require.NoError(t, err) + + // An invalid raw record fails the evidence encoder and aborts the attestation. + _, err = json.Marshal(parsed.RawRecords) + assert.NoErrorf(t, err, "marshal RawRecords (got %d records, want 3)", len(parsed.RawRecords)) +} diff --git a/app/cli/internal/trace/cursor/provider.go b/app/cli/internal/trace/cursor/provider.go new file mode 100644 index 000000000..dd41f64fe --- /dev/null +++ b/app/cli/internal/trace/cursor/provider.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 cursor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// Name is the provider identifier emitted to evidence and used as the key +// in SessionRecord.Provider. Kept as an exported constant so CLI wiring +// can refer to it without instantiating the provider. +const Name = "cursor" + +// agentName aliases Name for use inside this package's non-Provider code +// (parse output, log fields) so we don't collide with the Provider.Name() +// method name at read time. +const agentName = Name + +// Compile-time check that Provider implements trace.Provider. +var _ trace.Provider = (*Provider)(nil) + +// Provider implements trace.Provider for Cursor agent sessions. +type Provider struct{} + +// New creates a new Cursor provider. +func New() *Provider { + return &Provider{} +} + +// Name returns the agent identifier. +func (p *Provider) Name() string { + return agentName +} + +// DiscoverSession finds the most recent Cursor session for the given repo root. +func (p *Provider) DiscoverSession(repoRoot string) (*trace.DiscoveredSession, error) { + id, path, _, err := discoverCursorSession(repoRoot) + if err != nil || id == "" { + return nil, err + } + + return &trace.DiscoveredSession{ + SessionID: id, + SessionDir: filepath.Dir(path), + // Cursor does not expose a reliable "alive" signal from the transcript + // directory. Treat discovered sessions as potentially active. + IsActive: true, + }, nil +} + +// SessionDirForRepo returns the Cursor agent-transcripts directory for the repo. +func (p *Provider) SessionDirForRepo(repoRoot string) string { + return transcriptDirForRepo(repoRoot) +} + +// IsFileWritingTool returns true for the synthetic "edit" tool name we set on +// afterFileEdit hook inputs. Cursor has no tool-name concept for generic +// preToolUse/postToolUse events in our flow, so "edit" is the sole signal. +func (p *Provider) IsFileWritingTool(toolName string) bool { + return toolName == syntheticEditToolName +} + +// IsCommandTool always returns false for Cursor: shell-execution hooks are not +// yet wired (Cursor installs only sessionStart/sessionEnd/afterFileEdit), so +// shell-driven file changes are not captured for this provider. +func (p *Provider) IsCommandTool(_ string) bool { + return false +} + +// SystemMessage is a no-op for Cursor: the documented hook response channel +// does not include a "systemMessage"-style announcement path comparable to +// Claude Code's SessionStart output. We log nothing to stdout to avoid +// confusing Cursor's JSON response parser. +func (p *Provider) SystemMessage(_ string) error { + return nil +} + +// CaptureFileSnapshot is a no-op for Cursor: the afterFileEdit hook +// delivers old/new strings directly, so no pre-edit snapshot is needed. +func (p *Provider) CaptureFileSnapshot(_ *state.Store, _ *trace.HookInput) error { + return nil +} + +// ResolveBeforeContent reverses the edits reported by Cursor's +// afterFileEdit hook against the file's current content to reconstruct +// the pre-edit state. Returns nil when no edits were reported. +func (p *Provider) ResolveBeforeContent(_ *state.Store, input *trace.HookInput, after []byte) []byte { + if input == nil || len(input.Edits) == 0 { + return nil + } + + return attribution.ReverseApplyEdits(after, input.Edits) +} + +// CleanupAfterEdit is a no-op for Cursor: there's no per-edit state to +// release because no snapshot was taken. +func (p *Provider) CleanupAfterEdit(_ *state.Store, _ *trace.HookInput) {} + +// CopySessionData copies the Cursor transcript for sessionID into +// the store's raw/.jsonl. Handles both flat and nested +// source layouts; the destination is always flat so downstream consumers +// (parse, pre-push) don't need to re-resolve. +func (p *Provider) CopySessionData(store *state.Store, repoRoot, sessionID string) error { + sourceDir := p.SessionDirForRepo(repoRoot) + if sourceDir == "" { + return nil + } + + src, err := resolveSessionJSONL(sourceDir, sessionID) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err + } + + return store.CopyRawSessionFile(sessionID, src) +} + +// ParseSession reads the copied transcript JSONL for sessionID and returns +// a best-effort Evidence. Cursor's transcript format does not include token +// usage, so Usage stays empty and the result carries a warning. +func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) { + jsonlPath, err := state.FindRawSessionFile(opts.SessionDir, opts.SessionID) + if err != nil { + return nil, fmt.Errorf("locate cursor transcript: %w", err) + } + + parsed, err := parseCursorJSONL(jsonlPath) + if err != nil { + return nil, err + } + + info, err := os.Stat(jsonlPath) + var startedAt string + if err == nil { + startedAt = info.ModTime().UTC().Format(time.RFC3339) + } + + warnings := []string{ + "cursor transcripts do not expose token usage; usage and cost are reported as zero", + } + + rawSession := map[string][]json.RawMessage{"main": parsed.RawRecords} + if parsed.RawRecords == nil { + rawSession["main"] = []json.RawMessage{} + } + + return aicodingsession.NewEvidence(aicodingsession.Data{ + SchemaVersion: "v1", + Agent: aicodingsession.Agent{ + Name: agentName, + Version: opts.AgentVersion, + }, + Session: aicodingsession.Session{ + ID: opts.SessionID, + StartedAt: startedAt, + }, + Model: &aicodingsession.Model{ + Primary: opts.Model, + Provider: "cursor", + }, + ToolsUsed: buildToolsUsed(parsed.ToolCounts), + Conversation: &aicodingsession.Conversation{ + TotalMessages: parsed.UserMessages + parsed.AssistantMessages, + UserMessages: parsed.UserMessages, + AssistantMessages: parsed.AssistantMessages, + }, + RawSession: rawSession, + Warnings: warnings, + }), nil +} + +// buildToolsUsed converts a tool-name → invocation-count map into the +// Evidence summary, sorted by invocation count descending so the most-used +// tools surface first. +func buildToolsUsed(counts map[string]int) *aicodingsession.ToolsUsed { + if len(counts) == 0 { + return nil + } + + summary := make([]aicodingsession.ToolSummary, 0, len(counts)) + total := 0 + for name, count := range counts { + summary = append(summary, aicodingsession.ToolSummary{ToolName: name, InvocationCount: count}) + total += count + } + sort.Slice(summary, func(i, j int) bool { + if summary[i].InvocationCount != summary[j].InvocationCount { + return summary[i].InvocationCount > summary[j].InvocationCount + } + + return summary[i].ToolName < summary[j].ToolName + }) + + return &aicodingsession.ToolsUsed{ + Summary: summary, + TotalInvocations: total, + } +} diff --git a/app/cli/internal/trace/cursor/session.go b/app/cli/internal/trace/cursor/session.go new file mode 100644 index 000000000..afa5926b4 --- /dev/null +++ b/app/cli/internal/trace/cursor/session.go @@ -0,0 +1,148 @@ +// +// 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 cursor + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// sanitizeRepoPath encodes a repo root the way Cursor does for its projects +// directory: drop the leading path separator, replace any run of +// non-alphanumerics with a single dash. +// +// Example: /Users/me/proj/myrepo -> Users-me-proj-myrepo +func sanitizeRepoPath(repoRoot string) string { + s := strings.TrimPrefix(repoRoot, string(filepath.Separator)) + + var b strings.Builder + b.Grow(len(s)) + prevDash := false + for _, r := range s { + if isAlphanumeric(r) { + b.WriteRune(r) + prevDash = false + continue + } + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + + return strings.Trim(b.String(), "-") +} + +func isAlphanumeric(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + +// transcriptDirForRepo returns the Cursor agent-transcripts directory for a repo: +// ~/.cursor/projects//agent-transcripts/ +func transcriptDirForRepo(repoRoot string) string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(homeDir, ".cursor", "projects", sanitizeRepoPath(repoRoot), "agent-transcripts") +} + +// resolveSessionJSONL returns the path to the transcript file for sessionID +// inside dir, handling both the CLI flat layout (/.jsonl) and the +// IDE nested layout (//.jsonl). Returns an fs.ErrNotExist-wrapping +// error if neither candidate exists, and surfaces any other Stat error +// (e.g. permission denied) so callers don't silently treat it as missing. +func resolveSessionJSONL(dir, sessionID string) (string, error) { + flat := filepath.Join(dir, sessionID+".jsonl") + switch _, err := os.Stat(flat); { + case err == nil: + return flat, nil + case !errors.Is(err, os.ErrNotExist): + return "", fmt.Errorf("stat %q: %w", flat, err) + } + + nested := filepath.Join(dir, sessionID, sessionID+".jsonl") + switch _, err := os.Stat(nested); { + case err == nil: + return nested, nil + case !errors.Is(err, os.ErrNotExist): + return "", fmt.Errorf("stat %q: %w", nested, err) + } + + return "", fmt.Errorf("cursor transcript for %q not found in %q: %w", sessionID, dir, os.ErrNotExist) +} + +// discoverCursorSession walks the agent-transcripts directory for repoRoot +// and returns the most recently modified transcript file. Returns nil when +// no transcripts exist. +func discoverCursorSession(repoRoot string) (sessionID, jsonlPath string, modTime int64, err error) { + dir := transcriptDirForRepo(repoRoot) + if dir == "" { + return "", "", 0, nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", "", 0, nil + } + + return "", "", 0, err + } + + var bestID, bestPath string + var bestTime int64 + + for _, entry := range entries { + name := entry.Name() + var candidateID, candidatePath string + var candidateTime int64 + + switch { + case entry.IsDir(): + nested := filepath.Join(dir, name, name+".jsonl") + info, err := os.Stat(nested) + if err != nil { + continue + } + candidateID = name + candidatePath = nested + candidateTime = info.ModTime().UnixNano() + case strings.HasSuffix(name, ".jsonl"): + info, err := entry.Info() + if err != nil { + continue + } + candidateID = strings.TrimSuffix(name, ".jsonl") + candidatePath = filepath.Join(dir, name) + candidateTime = info.ModTime().UnixNano() + default: + continue + } + + if candidateTime > bestTime { + bestID = candidateID + bestPath = candidatePath + bestTime = candidateTime + } + } + + return bestID, bestPath, bestTime, nil +} diff --git a/app/cli/internal/trace/cursor/session_test.go b/app/cli/internal/trace/cursor/session_test.go new file mode 100644 index 000000000..df9d78d88 --- /dev/null +++ b/app/cli/internal/trace/cursor/session_test.go @@ -0,0 +1,70 @@ +// +// 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 cursor + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizeRepoPath(t *testing.T) { + tests := []struct { + in, want string + }{ + {"/Users/me/projects/myrepo", "Users-me-projects-myrepo"}, + {"/home/user/project-name", "home-user-project-name"}, + {"/a//b", "a-b"}, + {"/a/b.c/d", "a-b-c-d"}, + {"", ""}, + } + + for _, tc := range tests { + assert.Equal(t, tc.want, sanitizeRepoPath(tc.in), "sanitizeRepoPath(%q)", tc.in) + } +} + +func TestResolveSessionJSONLFlat(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "abc123.jsonl") + require.NoError(t, os.WriteFile(path, []byte("{}"), 0600)) + + got, err := resolveSessionJSONL(dir, "abc123") + require.NoError(t, err) + require.Equal(t, path, got) +} + +func TestResolveSessionJSONLNested(t *testing.T) { + dir := t.TempDir() + nestedDir := filepath.Join(dir, "abc123") + require.NoError(t, os.MkdirAll(nestedDir, 0755)) + path := filepath.Join(nestedDir, "abc123.jsonl") + require.NoError(t, os.WriteFile(path, []byte("{}"), 0600)) + + got, err := resolveSessionJSONL(dir, "abc123") + require.NoError(t, err) + require.Equal(t, path, got) +} + +func TestResolveSessionJSONLNotFound(t *testing.T) { + dir := t.TempDir() + _, err := resolveSessionJSONL(dir, "missing") + require.Error(t, err, "expected error for missing transcript") + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/app/cli/internal/trace/cursor/types.go b/app/cli/internal/trace/cursor/types.go new file mode 100644 index 000000000..fc985e286 --- /dev/null +++ b/app/cli/internal/trace/cursor/types.go @@ -0,0 +1,39 @@ +// +// 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 cursor + +// cursorJSONLRecord matches a single line in a Cursor agent transcript. +// The transcript schema is minimal compared to Claude Code's: only role + +// content-blocks of type "text". No tool_use blocks, no token usage. +type cursorJSONLRecord struct { + Role string `json:"role"` + Message cursorJSONLBody `json:"message"` +} + +type cursorJSONLBody struct { + Content []cursorContentBlock `json:"content"` +} + +type cursorContentBlock struct { + // Type is the block kind. Observed values: "text" (plain message body) + // and "tool_use" (assistant invoking a built-in tool such as ReadFile, + // ApplyPatch, StrReplace, Grep, Glob). + Type string `json:"type"` + // Text holds the message body for "text" blocks. + Text string `json:"text,omitempty"` + // Name holds the tool name for "tool_use" blocks (empty otherwise). + Name string `json:"name,omitempty"` +} diff --git a/app/cli/internal/trace/git/aggregate.go b/app/cli/internal/trace/git/aggregate.go new file mode 100644 index 000000000..e617802ca --- /dev/null +++ b/app/cli/internal/trace/git/aggregate.go @@ -0,0 +1,78 @@ +// +// 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 git + +import ( + "sort" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// fileAgg is the running per-path total used while accumulating diffs from a +// set of commits. Backends share it so the aggregation rule (sum lines, +// last-wins status) lives in one place. +type fileAgg struct { + linesAdded int + linesRemoved int + status string +} + +// addCommitStats folds one commit's diff into the running totals. +func addCommitStats(out *aicodingsession.CodeChanges, byPath map[string]*fileAgg, perCommit *aicodingsession.CodeChanges) { + out.LinesAdded += perCommit.LinesAdded + out.LinesRemoved += perCommit.LinesRemoved + + for _, f := range perCommit.Files { + agg, ok := byPath[f.Path] + if !ok { + agg = &fileAgg{} + byPath[f.Path] = agg + } + agg.linesAdded += f.LinesAdded + agg.linesRemoved += f.LinesRemoved + if f.Status != "" { + agg.status = f.Status + } + } +} + +// finalizeAggregatedFiles converts the running per-path map into a sorted +// Files slice and per-status counters on out. +func finalizeAggregatedFiles(out *aicodingsession.CodeChanges, byPath map[string]*fileAgg) { + paths := make([]string, 0, len(byPath)) + for p := range byPath { + paths = append(paths, p) + } + sort.Strings(paths) + + for _, p := range paths { + agg := byPath[p] + out.Files = append(out.Files, aicodingsession.FileChange{ + Path: p, + Status: agg.status, + LinesAdded: agg.linesAdded, + LinesRemoved: agg.linesRemoved, + }) + switch agg.status { + case StatusCreated: + out.FilesCreated++ + case StatusDeleted: + out.FilesDeleted++ + default: + out.FilesModified++ + } + } +} diff --git a/app/cli/internal/trace/git/exec.go b/app/cli/internal/trace/git/exec.go new file mode 100644 index 000000000..4d49eecb0 --- /dev/null +++ b/app/cli/internal/trace/git/exec.go @@ -0,0 +1,357 @@ +// +// 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 git + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// ExecClient implements Client by shelling out to the git binary. +type ExecClient struct{} + +var _ Client = (*ExecClient)(nil) + +func NewExecClient() *ExecClient { return &ExecClient{} } + +// resolveMergeBase finds the merge-base commit between the current branch and the default remote branch. +func resolveMergeBase(repoRoot string) string { + baseBranch := detectBaseBranch(repoRoot) + if baseBranch == "" { + return "" + } + + return gitOutput(repoRoot, "merge-base", baseBranch, "HEAD") +} + +// Context collects repository metadata: remote URL, branch, commits since base. +func (c *ExecClient) Context(repoRoot string) (*aicodingsession.GitContext, error) { + ctx := &aicodingsession.GitContext{ + WorkDir: repoRoot, + } + + ctx.Repository = gitOutput(repoRoot, "config", "--get", "remote.origin.url") + ctx.Branch = gitOutput(repoRoot, "rev-parse", "--abbrev-ref", "HEAD") + + return ctx, nil +} + +// CodeChanges collects file-level diff stats between the merge base and HEAD. +func (c *ExecClient) CodeChanges(repoRoot string) (*aicodingsession.CodeChanges, error) { + mergeBase := resolveMergeBase(repoRoot) + if mergeBase == "" { + return &aicodingsession.CodeChanges{}, nil + } + + return parseDiffStats(repoRoot, fmt.Sprintf("%s...HEAD", mergeBase)), nil +} + +// CodeChangesForRange collects file-level diff stats for a specific commit range. +// The range covers startSHA^..endSHA (changes introduced by the commits). +func (c *ExecClient) CodeChangesForRange(repoRoot, startSHA, endSHA string) (*aicodingsession.CodeChanges, error) { + rangeSpec := fmt.Sprintf("%s^..%s", startSHA, endSHA) + return parseDiffStats(repoRoot, rangeSpec), nil +} + +// LocalReachableSHAs returns the union of SHAs reachable from every local +// branch tip. Implemented as a single `git rev-list` invocation across all +// refs under refs/heads/. +func (c *ExecClient) LocalReachableSHAs(repoRoot string) (map[string]bool, error) { + out := gitOutput(repoRoot, "rev-list", "--branches=*") + if out == "" { + return map[string]bool{}, nil + } + + shas := make(map[string]bool) + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line != "" { + shas[line] = true + } + } + + return shas, nil +} + +// emptyTreeSHA is git's well-known SHA-1 hash of an empty tree +// (`git hash-object -t tree /dev/null`). Used as the "before" side when +// diffing a root commit, which has no parent for `^` to resolve against. +const emptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + +// CodeChangesForCommits aggregates per-commit (parent..commit) diffs across a +// possibly non-contiguous set of SHAs. See the GoGitClient counterpart for the +// rationale; this implementation walks each SHA via parseDiffStats and sums. +func (c *ExecClient) CodeChangesForCommits(repoRoot string, shas []string) (*aicodingsession.CodeChanges, error) { + out := &aicodingsession.CodeChanges{} + if len(shas) == 0 { + return out, nil + } + + byPath := make(map[string]*fileAgg) + for _, sha := range shas { + base := commitParentOrEmptyTree(repoRoot, sha) + addCommitStats(out, byPath, parseDiffStats(repoRoot, fmt.Sprintf("%s..%s", base, sha))) + } + finalizeAggregatedFiles(out, byPath) + + return out, nil +} + +// commitParentOrEmptyTree returns the parent SHA of `sha`, or the empty-tree +// SHA when `sha` is a root commit (`^` doesn't resolve). The fallback +// makes a root commit's diff the full tree as additions, matching the +// GoGitClient's zero-baseHash behavior. +func commitParentOrEmptyTree(repoRoot, sha string) string { + if parent := gitOutput(repoRoot, "rev-parse", "--verify", "--quiet", sha+"^"); parent != "" { + return parent + } + + return emptyTreeSHA +} + +// parseDiffStats runs git diff with the given range spec and returns parsed stats. +func parseDiffStats(repoRoot, rangeSpec string) *aicodingsession.CodeChanges { + info := &aicodingsession.CodeChanges{} + + // Collect per-file line stats from numstat + perFileStats := make(map[string][2]int) // path → [added, removed] + numstat := gitOutput(repoRoot, "diff", "--numstat", rangeSpec) + if numstat != "" { + for _, line := range strings.Split(numstat, "\n") { + fields := strings.SplitN(line, "\t", 3) + if len(fields) != 3 { + continue + } + + added, _ := strconv.Atoi(fields[0]) // binary files show "-" + removed, _ := strconv.Atoi(fields[1]) // which Atoi returns 0 + + info.LinesAdded += added + info.LinesRemoved += removed + + path := fields[2] + perFileStats[path] = [2]int{added, removed} + // For renames/copies numstat shows paths like "old => new" + // or brace-style "dir/{old => new}/file"; resolve to destination. + if dest := resolveNumstatRename(path); dest != "" { + perFileStats[dest] = [2]int{added, removed} + } + } + } + + nameStatus := gitOutput(repoRoot, "diff", "--name-status", rangeSpec) + if nameStatus != "" { + for _, line := range strings.Split(nameStatus, "\n") { + // Rename/copy lines have 3 fields: "R100\told\tnew", others have 2: "M\tfile" + fields := strings.Split(line, "\t") + if len(fields) < 2 { + continue + } + + status := mapGitStatus(fields[0]) + // Use the last field — for renames/copies this is the destination path + path := fields[len(fields)-1] + fci := aicodingsession.FileChange{ + Path: path, + Status: status, + } + if stats, ok := perFileStats[path]; ok { + fci.LinesAdded = stats[0] + fci.LinesRemoved = stats[1] + } + info.Files = append(info.Files, fci) + + switch status { + case StatusCreated: + info.FilesCreated++ + case StatusDeleted: + info.FilesDeleted++ + default: + info.FilesModified++ + } + } + } + + return info +} + +// resolveNumstatRename resolves a numstat rename path to the destination. +// Handles both simple "old => new" and brace-style "dir/{old => new}/file" patterns. +// Returns empty string if the path is not a rename. +func resolveNumstatRename(path string) string { + if !strings.Contains(path, " => ") { + return "" + } + + // Brace-style: "dir/{old => new}/suffix" + if braceStart := strings.Index(path, "{"); braceStart >= 0 { + if braceEnd := strings.Index(path, "}"); braceEnd > braceStart { + inner := path[braceStart+1 : braceEnd] + if _, dest, ok := strings.Cut(inner, " => "); ok { + prefix := path[:braceStart] + suffix := path[braceEnd+1:] + return prefix + dest + suffix + } + } + } + + // Simple: "old => new" + if _, dest, ok := strings.Cut(path, " => "); ok { + return dest + } + return "" +} + +// mapGitStatus converts a git diff status letter to a human-readable status. +func mapGitStatus(code string) string { + if len(code) == 0 { + return StatusModified + } + + switch code[0] { + case 'A': + return StatusCreated + case 'D': + return StatusDeleted + case 'R': + return StatusRenamed + case 'C': + return "copied" + default: + return StatusModified + } +} + +// GeneratedMatcher returns a predicate that reports whether a repo-relative path +// is marked linguist-generated=true, by shelling out to `git check-attr` per call. +// `git check-attr` returns "" on error (e.g. non-git directory), which degrades +// to a no-op matcher. +func (c *ExecClient) GeneratedMatcher(repoRoot string) func(path string) bool { + return func(path string) bool { + if path == "" { + return false + } + + // Output format: ": linguist-generated: " + // Values: "true", "false", "set" (bare attribute), "unspecified". + out := gitOutput(repoRoot, "check-attr", "linguist-generated", "--", path) + if out == "" { + return false + } + + idx := strings.LastIndex(out, ": ") + if idx < 0 { + return false + } + value := out[idx+2:] + + return value == "true" || value == "set" + } +} + +// CommitHeadInfo returns the SHA and message of HEAD. +func (c *ExecClient) CommitHeadInfo(repoRoot string) (sha, message string, err error) { + out := gitOutput(repoRoot, "log", "-1", "--format=%H%n%B", "HEAD") + if out == "" { + return "", "", fmt.Errorf("git log HEAD: no output") + } + + sha, message, _ = strings.Cut(out, "\n") + return sha, strings.TrimSpace(message), nil +} + +// StagedFiles returns repo-relative paths of files staged for the current commit. +func (c *ExecClient) StagedFiles(repoRoot string) ([]string, error) { + out := gitOutput(repoRoot, "diff", "--cached", "--name-only") + if out == "" { + return nil, nil + } + + return strings.Split(strings.TrimRight(out, "\n"), "\n"), nil +} + +// SnapshotWorktree returns a signature of the working tree (repo-relative path +// → content hash). The walk is git-binary-independent, so it shares the +// implementation used by GoGitClient. +func (c *ExecClient) SnapshotWorktree(repoRoot string) (map[string]string, error) { + return snapshotWorktree(repoRoot) +} + +// IsMergeCommit reports whether the commit at sha has more than one parent. +func (c *ExecClient) IsMergeCommit(repoRoot, sha string) (bool, error) { + cmd := exec.Command("git", "rev-list", "--no-walk", "--parents", sha) + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("rev-list %s: %w", sha, err) + } + + // Output format: " ..." + fields := strings.Fields(strings.TrimSpace(string(out))) + + return len(fields) > 2, nil +} + +// BranchCommitSHAs returns the SHAs reachable from HEAD that are not reachable +// from the default remote branch. The merge base is excluded. Order matches +// `git rev-list HEAD ^` (newest first). When no default remote branch +// is detected, returns every commit reachable from HEAD. +func (c *ExecClient) BranchCommitSHAs(repoRoot string) ([]string, error) { + args := []string{"rev-list", "HEAD"} + if base := detectBaseBranch(repoRoot); base != "" { + args = append(args, "^"+base) + } + + out := gitOutput(repoRoot, args...) + if out == "" { + return nil, nil + } + + return strings.Split(out, "\n"), nil +} + +// detectBaseBranch tries to find the default remote branch (main, master, etc.) +func detectBaseBranch(repoRoot string) string { + ref := gitOutput(repoRoot, "symbolic-ref", "refs/remotes/origin/HEAD") + if ref != "" { + return ref + } + + for _, name := range []string{"origin/main", "origin/master"} { + out := gitOutput(repoRoot, "rev-parse", "--verify", "--quiet", name) + if out != "" { + return name + } + } + + return "" +} + +// gitOutput runs a git command and returns trimmed stdout, or empty string on error. +func gitOutput(repoRoot string, args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(out)) +} diff --git a/app/cli/internal/trace/git/exec_test.go b/app/cli/internal/trace/git/exec_test.go new file mode 100644 index 000000000..f21b01e27 --- /dev/null +++ b/app/cli/internal/trace/git/exec_test.go @@ -0,0 +1,245 @@ +// +// 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 git + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecClient_Context(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewExecClient() + ctx, err := c.Context(cwd) + require.NoError(t, err) + + assert.NotEmpty(t, ctx.Repository, "repository should not be empty") + assert.NotEmpty(t, ctx.Branch, "branch should not be empty") + assert.Equal(t, cwd, ctx.WorkDir) +} + +func TestExecClient_ContextInvalidDir(t *testing.T) { + c := NewExecClient() + ctx, err := c.Context(t.TempDir()) + require.NoError(t, err) + + assert.Empty(t, ctx.Repository) + assert.Empty(t, ctx.Branch) +} + +func TestExecClient_CodeChanges(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewExecClient() + changes, err := c.CodeChanges(cwd) + require.NoError(t, err) + + assert.NotNil(t, changes) + assert.GreaterOrEqual(t, changes.FilesCreated+changes.FilesModified+changes.FilesDeleted, 0) +} + +func TestExecClient_CodeChangesInvalidDir(t *testing.T) { + c := NewExecClient() + changes, err := c.CodeChanges(t.TempDir()) + require.NoError(t, err) + assert.NotNil(t, changes) + assert.Empty(t, changes.Files) +} + +func TestExecClient_CodeChangesForRange(t *testing.T) { + // Create a temporary git repo with three commits so startSHA is never the + // root commit (startSHA^ must exist for the range to work). + dir := t.TempDir() + run := func(args ...string) string { return gitOutput(dir, args...) } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + require.NoError(t, os.WriteFile(dir+"/init.txt", []byte("init"), 0600)) + run("add", "init.txt") + run("commit", "-m", "init") + + require.NoError(t, os.WriteFile(dir+"/a.txt", []byte("hello"), 0600)) + run("add", "a.txt") + run("commit", "-m", "first") + first := run("rev-parse", "HEAD") + + require.NoError(t, os.WriteFile(dir+"/b.txt", []byte("world"), 0600)) + run("add", "b.txt") + run("commit", "-m", "second") + second := run("rev-parse", "HEAD") + + c := NewExecClient() + changes, err := c.CodeChangesForRange(dir, first, second) + require.NoError(t, err) + require.NotNil(t, changes) + require.Len(t, changes.Files, 2, "range should include a.txt and b.txt") + + paths := map[string]string{} + for _, f := range changes.Files { + paths[f.Path] = f.Status + } + assert.Equal(t, "created", paths["a.txt"]) + assert.Equal(t, "created", paths["b.txt"]) +} + +func TestExecClient_CodeChangesForRangeInvalidDir(t *testing.T) { + c := NewExecClient() + changes, err := c.CodeChangesForRange(t.TempDir(), "abc", "def") + require.NoError(t, err) + assert.NotNil(t, changes) + assert.Empty(t, changes.Files) +} + +func TestExecClient_CodeChangesForCommits(t *testing.T) { + dir := t.TempDir() + run := func(args ...string) string { return gitOutput(dir, args...) } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + require.NoError(t, os.WriteFile(dir+"/init.txt", []byte("init\n"), 0600)) + run("add", "init.txt") + run("commit", "-m", "init") + + // commit-A: creates a.txt with one line + require.NoError(t, os.WriteFile(dir+"/a.txt", []byte("alpha\n"), 0600)) + run("add", "a.txt") + run("commit", "-m", "A") + commitA := run("rev-parse", "HEAD") + + // commit-B (interleaved, NOT in our session): creates b.txt + require.NoError(t, os.WriteFile(dir+"/b.txt", []byte("bravo\n"), 0600)) + run("add", "b.txt") + run("commit", "-m", "B") + + // commit-C: extends a.txt with two more lines + require.NoError(t, os.WriteFile(dir+"/a.txt", []byte("alpha\nbeta\ngamma\n"), 0600)) + run("add", "a.txt") + run("commit", "-m", "C") + commitC := run("rev-parse", "HEAD") + + c := NewExecClient() + + t.Run("aggregates non-contiguous commits and excludes the gap", func(t *testing.T) { + changes, err := c.CodeChangesForCommits(dir, []string{commitA, commitC}) + require.NoError(t, err) + require.Len(t, changes.Files, 1, "only a.txt belongs to the queried set") + assert.Equal(t, "a.txt", changes.Files[0].Path) + assert.Equal(t, 3, changes.Files[0].LinesAdded, "1 line from A + 2 lines from C") + assert.Equal(t, 3, changes.LinesAdded) + }) + + t.Run("empty input returns empty result", func(t *testing.T) { + changes, err := c.CodeChangesForCommits(dir, nil) + require.NoError(t, err) + assert.Empty(t, changes.Files) + }) + + t.Run("root commit diffs against the empty tree", func(t *testing.T) { + // Fresh repo; a root commit has no parent, so `sha^` doesn't resolve. + // CodeChangesForCommits must fall back to the empty tree so the root + // commit's full content shows up as additions. + rootDir := t.TempDir() + runRoot := func(args ...string) string { return gitOutput(rootDir, args...) } + runRoot("init", "-b", "main") + runRoot("config", "user.email", "test@test.com") + runRoot("config", "user.name", "Test") + runRoot("config", "commit.gpgsign", "false") + + require.NoError(t, os.WriteFile(rootDir+"/seed.txt", []byte("seed1\nseed2\nseed3\n"), 0600)) + runRoot("add", "seed.txt") + runRoot("commit", "-m", "root") + rootSHA := runRoot("rev-parse", "HEAD") + + changes, err := c.CodeChangesForCommits(rootDir, []string{rootSHA}) + require.NoError(t, err) + require.Len(t, changes.Files, 1, "root commit's content must show up as additions") + assert.Equal(t, "seed.txt", changes.Files[0].Path) + assert.Equal(t, 3, changes.Files[0].LinesAdded) + }) +} + +func TestExecClient_GeneratedMatcher(t *testing.T) { + // Set up a real git repo so `git check-attr` has a working environment. + dir := t.TempDir() + run := func(args ...string) string { return gitOutput(dir, args...) } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".gitattributes"), + []byte( + "frontend/src/pbgen/** linguist-generated=true\n"+ + "gen/** linguist-generated\n"+ + "docs/** linguist-generated=false\n", + ), + 0o600, + )) + + c := NewExecClient() + m := c.GeneratedMatcher(dir) + + t.Run("linguist-generated=true matches", func(t *testing.T) { + assert.True(t, m("frontend/src/pbgen/foo.ts")) + assert.True(t, m("frontend/src/pbgen/nested/bar.ts")) + assert.False(t, m("frontend/src/components/foo.ts")) + }) + + t.Run("bare linguist-generated is treated as set", func(t *testing.T) { + assert.True(t, m("gen/out.pb.go")) + }) + + t.Run("linguist-generated=false returns false", func(t *testing.T) { + assert.False(t, m("docs/manual.md")) + }) + + t.Run("path not covered by any rule returns false", func(t *testing.T) { + assert.False(t, m("backend/internal/service/evidence.go")) + }) + + t.Run("empty path returns false", func(t *testing.T) { + assert.False(t, m("")) + }) +} + +func TestExecClient_GeneratedMatcherNonRepo(t *testing.T) { + // Non-git directory: `git check-attr` fails; matcher must degrade to false. + c := NewExecClient() + m := c.GeneratedMatcher(t.TempDir()) + assert.False(t, m("anything")) + assert.False(t, m("foo/bar.go")) +} + +func TestMapGitStatus(t *testing.T) { + assert.Equal(t, "created", mapGitStatus("A")) + assert.Equal(t, "deleted", mapGitStatus("D")) + assert.Equal(t, "renamed", mapGitStatus("R100")) + assert.Equal(t, "modified", mapGitStatus("M")) + assert.Equal(t, "modified", mapGitStatus("")) +} diff --git a/app/cli/internal/trace/git/git.go b/app/cli/internal/trace/git/git.go new file mode 100644 index 000000000..696b93663 --- /dev/null +++ b/app/cli/internal/trace/git/git.go @@ -0,0 +1,76 @@ +// +// 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 git + +import ( + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// File statuses reported in aicodingsession.FileChange.Status. The control +// plane buckets on these exact strings when it ingests the evidence, so they +// are a wire contract, not free-form labels. +const ( + StatusCreated = "created" + StatusModified = "modified" + StatusDeleted = "deleted" + StatusRenamed = "renamed" +) + +// Client abstracts git operations so the implementation can be swapped +// (e.g. from exec-based to go-git). +type Client interface { + // Context collects repository metadata: remote URL, branch, commits since base. + Context(repoRoot string) (*aicodingsession.GitContext, error) + // CodeChanges collects file-level diff stats between the merge base and HEAD. + CodeChanges(repoRoot string) (*aicodingsession.CodeChanges, error) + // CodeChangesForRange collects file-level diff stats for a specific commit range. + // The range covers startSHA^..endSHA (i.e. changes introduced by the commits). + CodeChangesForRange(repoRoot, startSHA, endSHA string) (*aicodingsession.CodeChanges, error) + // CodeChangesForCommits collects file-level diff stats for the union of the + // provided commit SHAs. Each commit's parent..commit diff is computed + // individually and summed by file path, so a non-contiguous set (e.g. one + // session's commits interleaved with another's on the same branch) is + // handled correctly. The order in which commits are visited is unspecified; + // per-file Status reflects the most recently visited commit's view. + CodeChangesForCommits(repoRoot string, shas []string) (*aicodingsession.CodeChanges, error) + // CommitHeadInfo returns the SHA and message of HEAD. + CommitHeadInfo(repoRoot string) (sha, message string, err error) + // GeneratedMatcher returns a predicate that reports whether a repo-relative + // path is marked linguist-generated=true in the repository's .gitattributes. + // Implementations must return a no-op matcher (always false) when attributes + // can't be read — filtering is best-effort and must not block attribution. + GeneratedMatcher(repoRoot string) func(path string) bool + // StagedFiles returns repo-relative paths of files staged for the current commit. + StagedFiles(repoRoot string) ([]string, error) + // SnapshotWorktree returns a signature of the working tree as a map of + // repo-relative path (forward slashes) → content hash, skipping the .git + // directory and any .gitignore'd paths. Used to detect files changed by an + // agent-run shell command by diffing a before/after signature. + SnapshotWorktree(repoRoot string) (map[string]string, error) + // IsMergeCommit reports whether the commit identified by sha has more than + // one parent. Returns an error when the commit cannot be resolved. + IsMergeCommit(repoRoot, sha string) (bool, error) + // BranchCommitSHAs returns the SHAs reachable from HEAD that are not + // reachable from the default remote branch (HEAD ^merge-base). The merge + // base itself is excluded. Order is unspecified. + BranchCommitSHAs(repoRoot string) ([]string, error) + // LocalReachableSHAs returns the union of SHAs reachable from every + // local branch tip (refs/heads/*). Used by the post-push GC to identify + // CommitRecords that are still alive on at least one branch — anything + // missing is an orphan from a deleted branch or a pre-rebase rewrite and + // is safe to drop. + LocalReachableSHAs(repoRoot string) (map[string]bool, error) +} diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go new file mode 100644 index 000000000..1d446531c --- /dev/null +++ b/app/cli/internal/trace/git/gogit.go @@ -0,0 +1,586 @@ +// +// 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 git + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + iofs "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/go-git/go-billy/v6/osfs" + gogit "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/format/gitattributes" + "github.com/go-git/go-git/v6/plumbing/format/gitignore" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/go-git/go-git/v6/utils/merkletrie" +) + +// openRepo opens a git repository, detecting .git from the given path or its parents. +// EnableDotGitCommonDir is set to support git worktrees. +func openRepo(path string) (*gogit.Repository, error) { + return gogit.PlainOpenWithOptions(path, &gogit.PlainOpenOptions{ + DetectDotGit: true, + }) +} + +// GoGitClient implements Client using go-git (no external git binary required). +type GoGitClient struct{} + +var _ Client = (*GoGitClient)(nil) + +func NewGoGitClient() *GoGitClient { return &GoGitClient{} } + +func (c *GoGitClient) Context(repoRoot string) (*aicodingsession.GitContext, error) { + ctx := &aicodingsession.GitContext{WorkDir: repoRoot} + + repo, err := openRepo(repoRoot) + if err != nil { + return ctx, nil + } + + // Remote URL + remote, err := repo.Remote("origin") + if err == nil && len(remote.Config().URLs) > 0 { + ctx.Repository = remote.Config().URLs[0] + } + + // Branch + head, err := repo.Head() + if err != nil { + return ctx, nil + } + if head.Name().IsBranch() { + ctx.Branch = head.Name().Short() + } else { + ctx.Branch = "HEAD" + } + + return ctx, nil +} + +func (c *GoGitClient) CodeChanges(repoRoot string) (*aicodingsession.CodeChanges, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + head, err := repo.Head() + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + baseBranch := detectBaseBranchGoGit(repo) + if baseBranch == nil { + return &aicodingsession.CodeChanges{}, nil + } + + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + baseCommit, err := repo.CommitObject(baseBranch.Hash()) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + mergeBaseCommits, err := headCommit.MergeBase(baseCommit) + if err != nil || len(mergeBaseCommits) == 0 { + return &aicodingsession.CodeChanges{}, nil + } + + return diffTrees(repo, mergeBaseCommits[0].Hash, head.Hash()) +} + +func (c *GoGitClient) CodeChangesForRange(repoRoot, startSHA, endSHA string) (*aicodingsession.CodeChanges, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + // We want changes introduced by startSHA through endSHA (startSHA^..endSHA). + startCommit, err := repo.CommitObject(plumbing.NewHash(startSHA)) + if err != nil { + return &aicodingsession.CodeChanges{}, nil + } + + // Use the first parent of startSHA as the base + var baseHash plumbing.Hash + parents := startCommit.Parents() + parent, err := parents.Next() + if err == nil { + baseHash = parent.Hash + } + // If no parent (root commit), baseHash is zero-value which gives an empty tree + + return diffTrees(repo, baseHash, plumbing.NewHash(endSHA)) +} + +// CodeChangesForCommits computes the union of per-commit (parent..commit) +// diffs for the given SHAs, summing line counts per file path. This is what +// runTracePush needs when a session's commits are non-contiguous on the branch +// — a SHA range diff would over-count by including unrelated commits in the +// gap. Unresolvable SHAs are skipped (best-effort) rather than failing the +// whole computation; that lets attestation continue when a stale CommitRecord +// references a SHA that's been pruned by git. +func (c *GoGitClient) CodeChangesForCommits(repoRoot string, shas []string) (*aicodingsession.CodeChanges, error) { + out := &aicodingsession.CodeChanges{} + if len(shas) == 0 { + return out, nil + } + + repo, err := openRepo(repoRoot) + if err != nil { + return out, nil + } + + byPath := make(map[string]*fileAgg) + for _, sha := range shas { + commit, err := repo.CommitObject(plumbing.NewHash(sha)) + if err != nil { + continue + } + + var baseHash plumbing.Hash + parents := commit.Parents() + if parent, err := parents.Next(); err == nil { + baseHash = parent.Hash + } + + stats, err := diffTrees(repo, baseHash, commit.Hash) + if err != nil { + continue + } + addCommitStats(out, byPath, stats) + } + finalizeAggregatedFiles(out, byPath) + + return out, nil +} + +func (c *GoGitClient) CommitHeadInfo(repoRoot string) (sha, message string, err error) { + repo, err := openRepo(repoRoot) + if err != nil { + return "", "", fmt.Errorf("open repo: %w", err) + } + + head, err := repo.Head() + if err != nil { + return "", "", fmt.Errorf("get HEAD: %w", err) + } + + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + return "", "", fmt.Errorf("get commit: %w", err) + } + + return head.Hash().String(), strings.TrimSpace(commit.Message), nil +} + +// GeneratedMatcher returns a predicate that reports whether a repo-relative path +// is marked linguist-generated=true in the repository's .gitattributes, parsed +// in-process via go-git's gitattributes parser. +func (c *GoGitClient) GeneratedMatcher(repoRoot string) func(path string) bool { + fs := osfs.New(repoRoot) + // allowMacro=false: macros are only valid in .git/info/attributes + attrs, err := gitattributes.ReadAttributesFile(fs, nil, ".gitattributes", false) + if err != nil || len(attrs) == 0 { + return func(string) bool { return false } + } + + // go-git's Matcher.Match iterates the stack from end to start and overwrites + // the result map on each match. With Match(parts, nil) the loop runs to + // completion, so the LAST iteration that matches wins — i.e. the FIRST entry + // in the stack. To get git's documented "last pattern in the file wins" + // semantics, reverse the stack here. Only safe because we always pass a nil + // attribute filter; a non-nil filter would invert the early-return logic. + for i, j := 0, len(attrs)-1; i < j; i, j = i+1, j-1 { + attrs[i], attrs[j] = attrs[j], attrs[i] + } + + matcher := gitattributes.NewMatcher(attrs) + return func(path string) bool { + if path == "" { + return false + } + parts := strings.Split(path, "/") + result, matched := matcher.Match(parts, nil) + if !matched { + return false + } + attr, ok := result["linguist-generated"] + if !ok { + return false + } + // `linguist-generated` alone is treated as set; `linguist-generated=true` + // is the explicit form used by GitHub Linguist. + if attr.IsSet() { + return true + } + if attr.IsValueSet() && strings.EqualFold(attr.Value(), "true") { + return true + } + + return false + } +} + +// StagedFiles returns repo-relative paths of files staged for the current commit. +func (c *GoGitClient) StagedFiles(repoRoot string) ([]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + wt, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("get worktree: %w", err) + } + + status, err := wt.Status() + if err != nil { + return nil, fmt.Errorf("get status: %w", err) + } + + var files []string + for path, s := range status { + if s.Staging != gogit.Unmodified && s.Staging != gogit.Untracked { + files = append(files, path) + } + } + + sort.Strings(files) + + return files, nil +} + +// SnapshotWorktree returns a signature of the working tree (repo-relative path +// → SHA-256 content hash), honoring .gitignore and skipping the .git directory. +func (c *GoGitClient) SnapshotWorktree(repoRoot string) (map[string]string, error) { + return snapshotWorktree(repoRoot) +} + +// snapshotWorktree walks repoRoot and hashes every regular, non-ignored file. +// gitignore patterns are read in-process via go-git's parser (best-effort: an +// unreadable .gitignore yields no patterns rather than an error). Symlinks and +// unreadable files are skipped. The walk never fails the caller — attribution +// is best-effort and must not block the agent. +func snapshotWorktree(repoRoot string) (map[string]string, error) { + wtfs := osfs.New(repoRoot) + patterns, _ := gitignore.ReadPatterns(wtfs, nil) + matcher := gitignore.NewMatcher(patterns) + tracked := trackedPaths(repoRoot) + + sig := make(map[string]string) + err := filepath.WalkDir(repoRoot, func(p string, d iofs.DirEntry, err error) error { + if err != nil { + return nil // skip unreadable entries, keep walking + } + + rel, relErr := filepath.Rel(repoRoot, p) + if relErr != nil || rel == "." { + return nil + } + slashRel := filepath.ToSlash(rel) + parts := strings.Split(slashRel, "/") + + if d.IsDir() { + if d.Name() == ".git" || matcher.Match(parts, true) { + return filepath.SkipDir + } + + return nil + } + + if !d.Type().IsRegular() { + return nil + } + // Ignore rules apply only to untracked files, matching git: a tracked + // file that also matches a .gitignore pattern (e.g. force-added + // generated code) is still part of the working tree and must be + // snapshotted so edits to it are attributed. (Tracked files nested + // inside an ignored directory are the one gap — that directory is + // pruned above for walk performance.) + if matcher.Match(parts, false) && !tracked[slashRel] { + return nil + } + + h, herr := hashFileContent(p) + if herr != nil { + return nil + } + sig[slashRel] = h + + return nil + }) + + return sig, err +} + +// trackedPaths returns the set of repo-relative paths (forward-slash separated) +// recorded in the git index. Best-effort: returns nil when the repository or +// its index cannot be read, in which case ignore rules apply to every file. +func trackedPaths(repoRoot string) map[string]bool { + repo, err := openRepo(repoRoot) + if err != nil { + return nil + } + + idx, err := repo.Storer.Index() + if err != nil { + return nil + } + + tracked := make(map[string]bool, len(idx.Entries)) + for _, e := range idx.Entries { + tracked[e.Name] = true + } + + return tracked +} + +// hashFileContent returns the hex-encoded SHA-256 of the file's content. +func hashFileContent(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// IsMergeCommit reports whether the commit at sha has more than one parent. +func (c *GoGitClient) IsMergeCommit(repoRoot, sha string) (bool, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return false, fmt.Errorf("open repo: %w", err) + } + + commit, err := repo.CommitObject(plumbing.NewHash(sha)) + if err != nil { + return false, fmt.Errorf("resolve commit %s: %w", sha, err) + } + + return commit.NumParents() > 1, nil +} + +// BranchCommitSHAs returns the SHAs reachable from HEAD that are not reachable +// from the default remote branch. The merge base is excluded. Order is +// unspecified. +func (c *GoGitClient) BranchCommitSHAs(repoRoot string) ([]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + head, err := repo.Head() + if err != nil { + return nil, fmt.Errorf("get HEAD: %w", err) + } + + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + return nil, fmt.Errorf("resolve HEAD commit: %w", err) + } + + // Pre-seed the iterator's seen-set with the merge base(s). The preorder + // iter skips any commit already in seen and stops descending into its + // parents, so a single walk from HEAD naturally prunes at the merge base. + seen := make(map[plumbing.Hash]bool) + if baseRef := detectBaseBranchGoGit(repo); baseRef != nil { + if baseCommit, err := repo.CommitObject(baseRef.Hash()); err == nil { + if mergeBases, err := headCommit.MergeBase(baseCommit); err == nil { + for _, mb := range mergeBases { + seen[mb.Hash] = true + } + } + } + } + + var shas []string + iter := object.NewCommitPreorderIter(headCommit, seen, nil) + if err := iter.ForEach(func(c *object.Commit) error { + shas = append(shas, c.Hash.String()) + + return nil + }); err != nil { + return nil, fmt.Errorf("walk commits: %w", err) + } + + return shas, nil +} + +// LocalReachableSHAs walks every local branch tip (refs/heads/*) and returns +// the union of reachable SHAs. The shared `seen` map both deduplicates work +// across branch walks (preorder iter skips already-seen commits and stops +// descending into their parents) and records every visited commit so the +// caller can build the live-SHA set. +func (c *GoGitClient) LocalReachableSHAs(repoRoot string) (map[string]bool, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + branches, err := repo.Branches() + if err != nil { + return nil, fmt.Errorf("list branches: %w", err) + } + defer branches.Close() + + // go-git's NewCommitPreorderIter accepts `seen` as an input-only skip set + // and does not mutate it. Track visits explicitly via the callback so the + // resulting set really is "everything reachable". + seen := make(map[plumbing.Hash]bool) + if err := branches.ForEach(func(ref *plumbing.Reference) error { + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + // Branch points at a non-commit (or pruned object) — skip rather + // than fail the whole GC. + return nil + } + iter := object.NewCommitPreorderIter(commit, seen, nil) + + return iter.ForEach(func(c *object.Commit) error { + seen[c.Hash] = true + + return nil + }) + }); err != nil { + return nil, fmt.Errorf("walk branches: %w", err) + } + + out := make(map[string]bool, len(seen)) + for h := range seen { + out[h.String()] = true + } + + return out, nil +} + +// detectBaseBranchGoGit finds the default remote branch reference. +func detectBaseBranchGoGit(repo *gogit.Repository) *plumbing.Reference { + // Try refs/remotes/origin/HEAD + ref, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", "HEAD"), true) + if err == nil { + return ref + } + + // Fallback to origin/main, then origin/master + for _, name := range []string{"main", "master"} { + ref, err = repo.Reference(plumbing.NewRemoteReferenceName("origin", name), true) + if err == nil { + return ref + } + } + + return nil +} + +// diffTrees computes diff stats between two commits identified by hash. +// A zero-value baseHash means diff against an empty tree. +// Note: go-git's tree diff does not detect renames/copies; they appear as delete+create. +func diffTrees(repo *gogit.Repository, baseHash, headHash plumbing.Hash) (*aicodingsession.CodeChanges, error) { + info := &aicodingsession.CodeChanges{} + + headCommit, err := repo.CommitObject(headHash) + if err != nil { + return info, nil + } + headTree, err := headCommit.Tree() + if err != nil { + return info, nil + } + + var baseTree *object.Tree + if !baseHash.IsZero() { + baseCommit, err := repo.CommitObject(baseHash) + if err != nil { + return info, nil + } + baseTree, err = baseCommit.Tree() + if err != nil { + return info, nil + } + } + + changes, err := object.DiffTree(baseTree, headTree) + if err != nil { + return info, nil + } + + // Collect file statuses from changes + for _, change := range changes { + action, err := change.Action() + if err != nil { + continue + } + + var status, path string + switch action { + case merkletrie.Insert: + status = StatusCreated + path = change.To.Name + info.FilesCreated++ + case merkletrie.Delete: + status = StatusDeleted + path = change.From.Name + info.FilesDeleted++ + default: + status = StatusModified + path = change.To.Name + info.FilesModified++ + } + + info.Files = append(info.Files, aicodingsession.FileChange{ + Path: path, + Status: status, + }) + } + + // Get line stats from patch and populate per-file counts + patch, err := changes.Patch() + if err != nil { + return info, nil + } + perFileStats := make(map[string][2]int) // path → [added, removed] + for _, stat := range patch.Stats() { + info.LinesAdded += stat.Addition + info.LinesRemoved += stat.Deletion + perFileStats[stat.Name] = [2]int{stat.Addition, stat.Deletion} + } + for i := range info.Files { + if stats, ok := perFileStats[info.Files[i].Path]; ok { + info.Files[i].LinesAdded = stats[0] + info.Files[i].LinesRemoved = stats[1] + } + } + + return info, nil +} diff --git a/app/cli/internal/trace/git/gogit_test.go b/app/cli/internal/trace/git/gogit_test.go new file mode 100644 index 000000000..08cbf3439 --- /dev/null +++ b/app/cli/internal/trace/git/gogit_test.go @@ -0,0 +1,536 @@ +// +// 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 git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + gogit "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGoGitClient_Context(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewGoGitClient() + ctx, err := c.Context(cwd) + require.NoError(t, err) + + assert.NotEmpty(t, ctx.Repository, "repository should not be empty") + assert.NotEmpty(t, ctx.Branch, "branch should not be empty") + assert.Equal(t, cwd, ctx.WorkDir) +} + +func TestGoGitClient_ContextInvalidDir(t *testing.T) { + c := NewGoGitClient() + ctx, err := c.Context(t.TempDir()) + require.NoError(t, err) + + assert.Empty(t, ctx.Repository) + assert.Empty(t, ctx.Branch) +} + +func TestGoGitClient_CodeChanges(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewGoGitClient() + changes, err := c.CodeChanges(cwd) + require.NoError(t, err) + + assert.NotNil(t, changes) + assert.GreaterOrEqual(t, changes.FilesCreated+changes.FilesModified+changes.FilesDeleted, 0) +} + +func TestGoGitClient_CodeChangesInvalidDir(t *testing.T) { + c := NewGoGitClient() + changes, err := c.CodeChanges(t.TempDir()) + require.NoError(t, err) + assert.NotNil(t, changes) + assert.Empty(t, changes.Files) +} + +func TestGoGitClient_CodeChangesForRange(t *testing.T) { + // Create a temporary git repo with three commits using go-git APIs. + dir := t.TempDir() + + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + cfg, err := repo.Config() + require.NoError(t, err) + cfg.Commit.GpgSign = config.NewOptBool(false) + require.NoError(t, repo.SetConfig(cfg)) + + wt, err := repo.Worktree() + require.NoError(t, err) + + sig := &object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Now(), + } + + // Init commit + require.NoError(t, os.WriteFile(dir+"/init.txt", []byte("init"), 0600)) + _, err = wt.Add("init.txt") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // First commit + require.NoError(t, os.WriteFile(dir+"/a.txt", []byte("hello"), 0600)) + _, err = wt.Add("a.txt") + require.NoError(t, err) + first, err := wt.Commit("first", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // Second commit + require.NoError(t, os.WriteFile(dir+"/b.txt", []byte("world"), 0600)) + _, err = wt.Add("b.txt") + require.NoError(t, err) + second, err := wt.Commit("second", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + c := NewGoGitClient() + changes, err := c.CodeChangesForRange(dir, first.String(), second.String()) + require.NoError(t, err) + require.NotNil(t, changes) + require.Len(t, changes.Files, 2, "range should include a.txt and b.txt") + + paths := map[string]string{} + for _, f := range changes.Files { + paths[f.Path] = f.Status + } + assert.Equal(t, "created", paths["a.txt"]) + assert.Equal(t, "created", paths["b.txt"]) +} + +func TestGoGitClient_CodeChangesForRangeInvalidDir(t *testing.T) { + c := NewGoGitClient() + changes, err := c.CodeChangesForRange(t.TempDir(), "abc", "def") + require.NoError(t, err) + assert.NotNil(t, changes) + assert.Empty(t, changes.Files) +} + +// CodeChangesForCommits aggregates a non-contiguous set of SHAs by summing +// each commit's parent..commit diff per file path. The contiguous-range +// counterpart can't do this — it would over-count commits in the gap. +func TestGoGitClient_CodeChangesForCommits(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + cfg, err := repo.Config() + require.NoError(t, err) + cfg.Commit.GpgSign = config.NewOptBool(false) + require.NoError(t, repo.SetConfig(cfg)) + wt, err := repo.Worktree() + require.NoError(t, err) + sig := &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()} + + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init\n"), 0600)) + _, err = wt.Add("init.txt") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // commit-A: creates a.txt (one line) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("alpha\n"), 0600)) + _, err = wt.Add("a.txt") + require.NoError(t, err) + commitA, err := wt.Commit("A", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // commit-B (interleaved, NOT in our session): creates b.txt + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("bravo\n"), 0600)) + _, err = wt.Add("b.txt") + require.NoError(t, err) + _, err = wt.Commit("B", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + // commit-C: extends a.txt with two more lines + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("alpha\nbeta\ngamma\n"), 0600)) + _, err = wt.Add("a.txt") + require.NoError(t, err) + commitC, err := wt.Commit("C", &gogit.CommitOptions{Author: sig}) + require.NoError(t, err) + + c := NewGoGitClient() + + t.Run("aggregates non-contiguous commits and excludes the gap", func(t *testing.T) { + // Session owns commit-A and commit-C; commit-B is human or another + // session's. Range [A..C] would over-count by including B's b.txt. + changes, err := c.CodeChangesForCommits(dir, []string{commitA.String(), commitC.String()}) + require.NoError(t, err) + require.Len(t, changes.Files, 1, "only a.txt belongs to the queried set") + assert.Equal(t, "a.txt", changes.Files[0].Path) + assert.Equal(t, 3, changes.Files[0].LinesAdded, "1 line from A + 2 lines from C") + assert.Equal(t, 0, changes.Files[0].LinesRemoved) + assert.Equal(t, 3, changes.LinesAdded) + }) + + t.Run("empty input returns empty result", func(t *testing.T) { + changes, err := c.CodeChangesForCommits(dir, nil) + require.NoError(t, err) + assert.Empty(t, changes.Files) + assert.Equal(t, 0, changes.LinesAdded) + }) + + t.Run("unresolvable SHA is skipped, not fatal", func(t *testing.T) { + changes, err := c.CodeChangesForCommits(dir, []string{commitA.String(), "deadbeef00000000000000000000000000000000"}) + require.NoError(t, err) + require.Len(t, changes.Files, 1) + assert.Equal(t, "a.txt", changes.Files[0].Path) + }) +} + +func TestGoGitClient_CommitHeadInfo(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + c := NewGoGitClient() + sha, message, err := c.CommitHeadInfo(cwd) + require.NoError(t, err) + + assert.NotEmpty(t, sha, "SHA should not be empty") + assert.NotEmpty(t, message, "message should not be empty") + assert.Len(t, sha, 40, "SHA should be 40 hex characters") +} + +func TestGoGitClient_CommitHeadInfoInvalidDir(t *testing.T) { + c := NewGoGitClient() + _, _, err := c.CommitHeadInfo(t.TempDir()) + assert.Error(t, err) +} + +func TestGoGitClient_GeneratedMatcher(t *testing.T) { + writeGitAttributes := func(t *testing.T, dir, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte(content), 0o600)) + } + + t.Run("missing .gitattributes returns no-op matcher", func(t *testing.T) { + m := NewGoGitClient().GeneratedMatcher(t.TempDir()) + assert.False(t, m("frontend/src/pbgen/foo.ts")) + assert.False(t, m("anything")) + }) + + t.Run("linguist-generated=true pattern matches nested paths", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "frontend/src/pbgen/** linguist-generated=true\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("frontend/src/pbgen/foo.ts")) + assert.True(t, m("frontend/src/pbgen/nested/bar.ts")) + assert.False(t, m("frontend/src/components/foo.ts")) + }) + + t.Run("bare linguist-generated is treated as set", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "gen/** linguist-generated\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("gen/out.pb.go")) + }) + + t.Run("linguist-generated=false override wins for nested paths", func(t *testing.T) { + dir := t.TempDir() + // Order matters: later rules override earlier ones for the same attribute. + writeGitAttributes(t, dir, + "backend/ent/** linguist-generated=true\n"+ + "backend/ent/schema/* linguist-generated=false\n", + ) + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("backend/ent/client.go")) + assert.False(t, m("backend/ent/schema/user.go")) + }) + + t.Run("path not covered returns false", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "frontend/src/pbgen/** linguist-generated=true\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.False(t, m("backend/internal/service/evidence.go")) + assert.False(t, m("")) + }) + + t.Run("unrelated attributes do not trigger generated flag", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, + "*.go diff=golang\n"+ + "*.png binary\n", + ) + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.False(t, m("main.go")) + assert.False(t, m("logo.png")) + }) + + t.Run("linguist-generated=true matches deeply nested paths", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "frontend/src/pbgen/** linguist-generated=true\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("frontend/src/pbgen/backend/v1/shared_messages_pb.ts")) + assert.True(t, m("frontend/src/pbgen/a/b/c/d/e.ts")) + }) + + t.Run("**/*.pb.go matches generated proto Go files anywhere", func(t *testing.T) { + dir := t.TempDir() + writeGitAttributes(t, dir, "**/*.pb.go linguist-generated=true\n") + + m := NewGoGitClient().GeneratedMatcher(dir) + assert.True(t, m("backend/api/gen/backend/v1/shared_messages.pb.go")) + assert.True(t, m("backend/internal/conf/backend/config/v1/conf.pb.go")) + assert.False(t, m("backend/internal/conf/backend/config/v1/conf.proto")) + }) +} + +// TestGoGitClient_GeneratedMatcherProjectAttributes exercises the matcher against +// the real project .gitattributes, locking in the expected classification of +// representative paths under git's "last match wins" semantics. +func TestGoGitClient_GeneratedMatcherProjectAttributes(t *testing.T) { + _, repoRoot, err := FindGitDirAndRoot() + require.NoError(t, err) + + m := NewGoGitClient().GeneratedMatcher(repoRoot) + + // Generated by entgo, protoc and wire: must be filtered. + assert.True(t, m("app/controlplane/pkg/data/ent/client.go")) + assert.True(t, m("app/controlplane/pkg/data/ent/organization.go")) + assert.True(t, m("app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts")) + assert.True(t, m("app/controlplane/api/gen/jsonschema/foo.json")) + assert.True(t, m("app/controlplane/api/controlplane/v1/response_messages.pb.go")) + assert.True(t, m("app/controlplane/api/controlplane/v1/response_messages.pb.validate.go")) + assert.True(t, m("app/controlplane/cmd/wire_gen.go")) + + // Hand-managed despite living under a generated tree: the later + // linguist-generated=false lines win under "last match wins". + assert.False(t, m("app/controlplane/pkg/data/ent/migrate/migrate.go")) + assert.False(t, m("app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum")) + assert.False(t, m("app/controlplane/pkg/data/ent/schema/workflow.go")) + + // Unrelated source: not generated. + assert.False(t, m("app/cli/cmd/root.go")) + assert.False(t, m("pkg/attestation/crafter/crafter.go")) + assert.False(t, m("app/cli/internal/trace/git/gogit.go")) +} + +// TestGoGitClient_Worktree exercises all Client methods from a linked git worktree. +// Setup uses exec for worktree creation (go-git doesn't support creating linked worktrees), +// then verifies the GoGitClient can correctly read from the worktree. +func TestGoGitClient_Worktree(t *testing.T) { + // Skip if git binary is not available (needed for worktree setup only) + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found, skipping worktree test") + } + + // --- Setup: create main repo with origin remote and a linked worktree --- + mainDir := t.TempDir() + wtDir := filepath.Join(t.TempDir(), "linked-wt") + + // Initialize main repo using exec (to respect system default branch name) + run := func(args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = mainDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(out)) + return strings.TrimSpace(string(out)) + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + + require.NoError(t, os.WriteFile(filepath.Join(mainDir, "init.txt"), []byte("init"), 0600)) + run("add", "init.txt") + run("commit", "-m", "initial commit") + + // Add a fake origin remote so Context() can read the remote URL + run("remote", "add", "origin", "https://github.com/example/repo.git") + // Create origin/main ref so merge-base detection works + run("update-ref", "refs/remotes/origin/main", "HEAD") + + // Create a feature branch and add commits + run("checkout", "-b", "feature") + + require.NoError(t, os.WriteFile(filepath.Join(mainDir, "a.txt"), []byte("hello"), 0600)) + run("add", "a.txt") + run("commit", "-m", "first feature commit") + firstHash := run("rev-parse", "HEAD") + + require.NoError(t, os.WriteFile(filepath.Join(mainDir, "b.txt"), []byte("world"), 0600)) + run("add", "b.txt") + run("commit", "-m", "second feature commit") + secondHash := run("rev-parse", "HEAD") + + // Switch main repo back to main before creating the linked worktree + run("checkout", "main") + + // Create a linked worktree on the feature branch (requires exec) + cmd := exec.Command("git", "worktree", "add", wtDir, "feature") + cmd.Dir = mainDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git worktree add failed: %s", string(out)) + + // --- Test: all GoGitClient methods from the linked worktree --- + c := NewGoGitClient() + + t.Run("Context", func(t *testing.T) { + ctx, err := c.Context(wtDir) + require.NoError(t, err) + + assert.Equal(t, "https://github.com/example/repo.git", ctx.Repository) + assert.Equal(t, "feature", ctx.Branch) + assert.Equal(t, wtDir, ctx.WorkDir) + assert.Equal(t, 0, ctx.CommitCount, "Context should not collect commits (overridden per-session)") + assert.Empty(t, ctx.CommitStart) + assert.Empty(t, ctx.CommitEnd) + }) + + t.Run("CommitHeadInfo", func(t *testing.T) { + sha, message, err := c.CommitHeadInfo(wtDir) + require.NoError(t, err) + + assert.Equal(t, secondHash, sha) + assert.Equal(t, "second feature commit", message) + }) + + t.Run("CodeChangesForRange", func(t *testing.T) { + changes, err := c.CodeChangesForRange(wtDir, firstHash, secondHash) + require.NoError(t, err) + require.Len(t, changes.Files, 2, "range should include a.txt and b.txt") + + paths := map[string]string{} + for _, f := range changes.Files { + paths[f.Path] = f.Status + } + assert.Equal(t, "created", paths["a.txt"]) + assert.Equal(t, "created", paths["b.txt"]) + assert.Greater(t, changes.LinesAdded, 0) + }) + + t.Run("CodeChanges", func(t *testing.T) { + changes, err := c.CodeChanges(wtDir) + require.NoError(t, err) + assert.NotNil(t, changes) + assert.GreaterOrEqual(t, changes.FilesCreated, 2) + }) + + t.Run("FindGitDirAndRoot", func(t *testing.T) { + // Save and restore cwd + origDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(origDir) }() + + require.NoError(t, os.Chdir(wtDir)) + + gitDir, repoRoot, err := FindGitDirAndRoot() + require.NoError(t, err) + + // Resolve symlinks for comparison (macOS /var -> /private/var) + resolvedWtDir, err := filepath.EvalSymlinks(wtDir) + require.NoError(t, err) + resolvedMainDir, err := filepath.EvalSymlinks(mainDir) + require.NoError(t, err) + + assert.Equal(t, resolvedWtDir, repoRoot, "repoRoot should be the worktree directory") + // gitDir should point to the worktree-specific git dir inside the main repo's .git/worktrees/ + assert.Contains(t, gitDir, "worktrees", "gitDir should be inside .git/worktrees/") + assert.True(t, strings.HasPrefix(gitDir, resolvedMainDir), "gitDir should be under the main repo") + + // The gitDir should actually exist + _, err = os.Stat(gitDir) + assert.NoError(t, err, "gitDir should exist on disk") + }) +} + +func TestGoGitClient_SnapshotWorktree(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "keep.txt"), []byte("hello\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "sub"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "nested.go"), []byte("package sub\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".gitignore"), []byte("ignored.txt\nbuild/\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "ignored.txt"), []byte("secret\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "build"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "build", "out.bin"), []byte("artifact\n"), 0600)) + // A .git directory must always be skipped, even without a real repo. + require.NoError(t, os.MkdirAll(filepath.Join(root, ".git"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".git", "config"), []byte("[core]\n"), 0600)) + + c := NewGoGitClient() + sig, err := c.SnapshotWorktree(root) + require.NoError(t, err) + + // Non-ignored files present (forward-slash keys), ignored + .git excluded. + assert.Contains(t, sig, "keep.txt") + assert.Contains(t, sig, "sub/nested.go") + assert.Contains(t, sig, ".gitignore") + assert.NotContains(t, sig, "ignored.txt") + assert.NotContains(t, sig, "build/out.bin") + assert.NotContains(t, sig, ".git/config") + + // Modifying a file changes its signature; adding one adds a key. + require.NoError(t, os.WriteFile(filepath.Join(root, "keep.txt"), []byte("hello world\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "new.txt"), []byte("new\n"), 0600)) + sig2, err := c.SnapshotWorktree(root) + require.NoError(t, err) + assert.NotEqual(t, sig["keep.txt"], sig2["keep.txt"]) + assert.Contains(t, sig2, "new.txt") +} + +func TestGoGitClient_SnapshotWorktree_TrackedIgnoredFile(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found") + } + root := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = root + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + run("init", "-b", "main") + run("config", "user.email", "t@t.com") + run("config", "user.name", "t") + + // *.gen is ignored, but one .gen file is force-added and tracked. + require.NoError(t, os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.gen\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tracked.gen"), []byte("tracked\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "untracked.gen"), []byte("untracked\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "normal.txt"), []byte("normal\n"), 0600)) + run("add", "-f", "tracked.gen") + run("add", "normal.txt", ".gitignore") + run("commit", "-m", "init") + + sig, err := NewGoGitClient().SnapshotWorktree(root) + require.NoError(t, err) + + assert.Contains(t, sig, "tracked.gen", "force-added tracked file must be snapshotted despite matching .gitignore") + assert.Contains(t, sig, "normal.txt") + assert.NotContains(t, sig, "untracked.gen", "untracked ignored file must be excluded") +} diff --git a/app/cli/internal/trace/git/merge_branch_test.go b/app/cli/internal/trace/git/merge_branch_test.go new file mode 100644 index 000000000..a7a14330d --- /dev/null +++ b/app/cli/internal/trace/git/merge_branch_test.go @@ -0,0 +1,197 @@ +// +// 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 git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// branchedRepo holds the directory and commit SHAs produced by +// setupBranchedRepo, in chronological order. +type branchedRepo struct { + dir string + initialSHA string + featureA string + featureB string + sideSHA string + mergeSHA string +} + +// setupBranchedRepo creates a repo with: +// - main branch with one initial commit +// - origin/main ref tracking that commit +// - feature branch with two additional commits, then a merge of a side branch +func setupBranchedRepo(t *testing.T) branchedRepo { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found") + } + + dir := t.TempDir() + run := func(args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) + + return string(out) + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0600)) + run("add", "init.txt") + run("commit", "-m", "initial") + initialSHA := strings.TrimSpace(run("rev-parse", "HEAD")) + + // Make origin/main point at the initial commit so detectBaseBranchGoGit finds it. + run("update-ref", "refs/remotes/origin/main", "HEAD") + + // Feature branch with two commits. + run("checkout", "-b", "feature") + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0600)) + run("add", "a.txt") + run("commit", "-m", "feature a") + featureA := strings.TrimSpace(run("rev-parse", "HEAD")) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world"), 0600)) + run("add", "b.txt") + run("commit", "-m", "feature b") + featureB := strings.TrimSpace(run("rev-parse", "HEAD")) + + // Side branch off main. + run("branch", "side", initialSHA) + run("checkout", "side") + require.NoError(t, os.WriteFile(filepath.Join(dir, "c.txt"), []byte("side"), 0600)) + run("add", "c.txt") + run("commit", "-m", "side commit") + sideSHA := strings.TrimSpace(run("rev-parse", "HEAD")) + + // Merge side into feature with a real merge commit. + run("checkout", "feature") + run("merge", "--no-ff", "-m", "merge side", "side") + mergeSHA := strings.TrimSpace(run("rev-parse", "HEAD")) + + return branchedRepo{ + dir: dir, + initialSHA: initialSHA, + featureA: featureA, + featureB: featureB, + sideSHA: sideSHA, + mergeSHA: mergeSHA, + } +} + +func TestGoGitClient_IsMergeCommit(t *testing.T) { + repo := setupBranchedRepo(t) + c := NewGoGitClient() + + t.Run("regular commit", func(t *testing.T) { + isMerge, err := c.IsMergeCommit(repo.dir, repo.featureA) + require.NoError(t, err) + assert.False(t, isMerge) + }) + + t.Run("merge commit", func(t *testing.T) { + isMerge, err := c.IsMergeCommit(repo.dir, repo.mergeSHA) + require.NoError(t, err) + assert.True(t, isMerge) + }) + + t.Run("unknown sha", func(t *testing.T) { + _, err := c.IsMergeCommit(repo.dir, "0000000000000000000000000000000000000000") + assert.Error(t, err) + }) +} + +func TestGoGitClient_BranchCommitSHAs(t *testing.T) { + repo := setupBranchedRepo(t) + c := NewGoGitClient() + + shas, err := c.BranchCommitSHAs(repo.dir) + require.NoError(t, err) + + // Set form: feature branch ahead of origin/main is repo.featureA, repo.featureB, + // repo.sideSHA, repo.mergeSHA. The merge base (initial) itself is excluded. + got := map[string]bool{} + for _, sha := range shas { + got[sha] = true + } + + assert.True(t, got[repo.featureA], "repo.featureA should be in branch SHAs") + assert.True(t, got[repo.featureB], "repo.featureB should be in branch SHAs") + assert.True(t, got[repo.sideSHA], "repo.sideSHA should be in branch SHAs (reachable via merge)") + assert.True(t, got[repo.mergeSHA], "repo.mergeSHA should be in branch SHAs") + assert.False(t, got[repo.initialSHA], "merge base (initial) should be excluded") +} + +func TestExecClient_IsMergeCommit(t *testing.T) { + repo := setupBranchedRepo(t) + c := NewExecClient() + + t.Run("regular commit", func(t *testing.T) { + isMerge, err := c.IsMergeCommit(repo.dir, repo.featureA) + require.NoError(t, err) + assert.False(t, isMerge) + }) + + t.Run("merge commit", func(t *testing.T) { + isMerge, err := c.IsMergeCommit(repo.dir, repo.mergeSHA) + require.NoError(t, err) + assert.True(t, isMerge) + }) +} + +func TestIsMergeInProgress(t *testing.T) { + t.Run("no merge head present", func(t *testing.T) { + assert.False(t, IsMergeInProgress(t.TempDir())) + }) + + t.Run("merge head present", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "MERGE_HEAD"), []byte("abc\n"), 0600)) + assert.True(t, IsMergeInProgress(dir)) + }) +} + +func TestExecClient_BranchCommitSHAs(t *testing.T) { + repo := setupBranchedRepo(t) + c := NewExecClient() + + shas, err := c.BranchCommitSHAs(repo.dir) + require.NoError(t, err) + + got := map[string]bool{} + for _, sha := range shas { + got[sha] = true + } + + assert.True(t, got[repo.featureA]) + assert.True(t, got[repo.featureB]) + assert.True(t, got[repo.sideSHA]) + assert.True(t, got[repo.mergeSHA]) + assert.False(t, got[repo.initialSHA]) +} diff --git a/app/cli/internal/trace/git/repo.go b/app/cli/internal/trace/git/repo.go new file mode 100644 index 000000000..6c1b6a0a0 --- /dev/null +++ b/app/cli/internal/trace/git/repo.go @@ -0,0 +1,119 @@ +// +// 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 git + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ErrNotARepository reports that no .git was found from cwd up to root. It is +// the only error callers should read as "fall back to non-git mode": every +// other failure (unreadable cwd, malformed .git file) means a repository is +// there but broken, and must be surfaced. +var ErrNotARepository = errors.New("not a git repository (or any parent up to root)") + +// FindGitDir returns the path to the .git directory for the current repository. +func FindGitDir() (string, error) { + gitDir, _, err := findGitDirAndRoot() + return gitDir, err +} + +// RepoRoot returns the top-level directory of the current git repository. +func RepoRoot() (string, error) { + _, root, err := findGitDirAndRoot() + return root, err +} + +// FindGitDirAndRoot returns both the .git directory and repo root. +func FindGitDirAndRoot() (gitDir, repoRoot string, err error) { + return findGitDirAndRoot() +} + +// findGitDirAndRoot walks up from cwd to locate .git, avoiding go-git's strict +// config validation which fails on repos with invalid branch config. +func findGitDirAndRoot() (string, string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("get working directory: %w", err) + } + + dir := cwd + for { + dotGit := filepath.Join(dir, ".git") + fi, err := os.Lstat(dotGit) + if err == nil { + if fi.IsDir() { + return dotGit, dir, nil + } + + // .git is a file (worktree) — parse "gitdir: " + gitDir, err := resolveGitFile(dotGit) + if err != nil { + return "", "", fmt.Errorf("resolve .git file: %w", err) + } + + return gitDir, dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", "", ErrNotARepository +} + +// IsMergeInProgress reports whether a merge is in progress in the given .git +// directory. Detected via the presence of the MERGE_HEAD file, which git +// creates when a `git merge` cannot fast-forward and stops to let the user +// produce the merge commit. +func IsMergeInProgress(gitDir string) bool { + _, err := os.Stat(filepath.Join(gitDir, "MERGE_HEAD")) + + return err == nil +} + +// resolveGitFile reads a .git file (used in worktrees) and resolves +// the gitdir path it points to. +func resolveGitFile(dotGitFile string) (string, error) { + data, err := os.ReadFile(dotGitFile) + if err != nil { + return "", fmt.Errorf("read .git file: %w", err) + } + + line := strings.TrimSpace(string(data)) + if !strings.HasPrefix(line, "gitdir: ") { + return "", fmt.Errorf("unexpected .git file content: %s", line) + } + + gitDir := strings.TrimPrefix(line, "gitdir: ") + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(filepath.Dir(dotGitFile), gitDir) + } + + gitDir = filepath.Clean(gitDir) + if _, err := os.Stat(gitDir); err != nil { + return "", fmt.Errorf("gitdir path does not exist: %w", err) + } + + return gitDir, nil +} diff --git a/app/cli/internal/trace/git/repo_test.go b/app/cli/internal/trace/git/repo_test.go new file mode 100644 index 000000000..e2f297f2d --- /dev/null +++ b/app/cli/internal/trace/git/repo_test.go @@ -0,0 +1,212 @@ +// +// 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 git + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindGitDir(t *testing.T) { + gitDir, err := FindGitDir() + require.NoError(t, err) + assert.NotEmpty(t, gitDir) + assert.True(t, filepath.IsAbs(gitDir), "should return absolute path") + + // The .git directory should exist + _, err = os.Stat(gitDir) + assert.NoError(t, err) +} + +func TestRepoRoot(t *testing.T) { + root, err := RepoRoot() + require.NoError(t, err) + assert.NotEmpty(t, root) + assert.True(t, filepath.IsAbs(root), "should return absolute path") + + // The repo root should contain a .git dir + _, err = os.Stat(filepath.Join(root, ".git")) + assert.NoError(t, err) +} + +func TestFindGitDirAndRoot(t *testing.T) { + gitDir, repoRoot, err := FindGitDirAndRoot() + require.NoError(t, err) + + assert.NotEmpty(t, gitDir) + assert.NotEmpty(t, repoRoot) + // gitDir may differ from repoRoot/.git in worktree setups + _, err = os.Stat(gitDir) + assert.NoError(t, err, "gitDir should exist") + _, err = os.Stat(repoRoot) + assert.NoError(t, err, "repoRoot should exist") +} + +func TestFindGitDirFromNonGitDir(t *testing.T) { + // Save and restore cwd + origDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(origDir) }() + + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + + _, err = FindGitDir() + assert.Error(t, err) +} + +func TestFindGitDirAndRootWithCorruptConfig(t *testing.T) { + origDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(origDir) }() + + repoDir := t.TempDir() + gitDir := filepath.Join(repoDir, ".git") + require.NoError(t, os.MkdirAll(gitDir, 0o755)) + + // Invalid branch merge ref triggers go-git's strict config validation + configContent := `[core] + repositoryformatversion = 0 +[branch "broken"] + merge = not-a-valid-ref +` + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "config"), []byte(configContent), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o600)) + + require.NoError(t, os.Chdir(repoDir)) + + foundGitDir, foundRoot, err := FindGitDirAndRoot() + require.NoError(t, err, "should succeed despite corrupt branch config") + + // Resolve symlinks for macOS where /var -> /private/var + expectedGitDir, err := filepath.EvalSymlinks(gitDir) + require.NoError(t, err) + expectedRoot, err := filepath.EvalSymlinks(repoDir) + require.NoError(t, err) + assert.Equal(t, expectedGitDir, foundGitDir) + assert.Equal(t, expectedRoot, foundRoot) +} + +func TestResolveGitFile(t *testing.T) { + // Create a fake worktree setup: .git file pointing to a gitdir + tmpDir := t.TempDir() + realGitDir := filepath.Join(tmpDir, "real-gitdir") + require.NoError(t, os.MkdirAll(realGitDir, 0o755)) + + dotGitFile := filepath.Join(tmpDir, ".git") + require.NoError(t, os.WriteFile(dotGitFile, []byte("gitdir: "+realGitDir+"\n"), 0o600)) + + resolved, err := resolveGitFile(dotGitFile) + require.NoError(t, err) + assert.Equal(t, realGitDir, resolved) +} + +func TestResolveGitFileRelativePath(t *testing.T) { + tmpDir := t.TempDir() + realGitDir := filepath.Join(tmpDir, "worktrees", "my-branch") + require.NoError(t, os.MkdirAll(realGitDir, 0o755)) + + dotGitFile := filepath.Join(tmpDir, ".git") + require.NoError(t, os.WriteFile(dotGitFile, []byte("gitdir: worktrees/my-branch\n"), 0o600)) + + resolved, err := resolveGitFile(dotGitFile) + require.NoError(t, err) + assert.Equal(t, realGitDir, resolved) +} + +// TestFindGitDirAndRootBrokenGitFileIsNotNotFound guards the trace run +// fallback: only ErrNotARepository may be read as "no repository here", so a +// present-but-unusable .git surfaces instead of silently disabling git +// attribution. +func TestFindGitDirAndRootBrokenGitFileIsNotNotFound(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".git"), []byte("garbage\n"), 0o600)) + t.Chdir(dir) + + _, _, err := FindGitDirAndRoot() + require.Error(t, err) + assert.NotErrorIs(t, err, ErrNotARepository) +} + +func TestResolveGitFileInvalidContent(t *testing.T) { + tmpDir := t.TempDir() + dotGitFile := filepath.Join(tmpDir, ".git") + require.NoError(t, os.WriteFile(dotGitFile, []byte("not a gitdir pointer\n"), 0o600)) + + _, err := resolveGitFile(dotGitFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected .git file content") +} + +// TestHooksInstallFromWorktree is the integration counterpart to the +// synthetic worktree test in cli/internal/trace/hooks: it sets up a real +// linked worktree via the git binary, then verifies that hooks.Install +// given the worktree-private gitDir from FindGitDirAndRoot lands the +// hooks under the main repo's .git/hooks (the only place git reads them). +func TestHooksInstallFromWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found, skipping worktree test") + } + + mainDir := t.TempDir() + wtDir := filepath.Join(t.TempDir(), "linked-wt") + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = mainDir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(out)) + } + + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("commit", "--allow-empty", "-m", "init") + run("worktree", "add", wtDir, "-b", "feature") + + origDir, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(origDir) }() + require.NoError(t, os.Chdir(wtDir)) + + worktreeGitDir, _, err := FindGitDirAndRoot() + require.NoError(t, err) + + hooksDir, err := hooks.Install(worktreeGitDir, false) + require.NoError(t, err) + + resolvedMainDir, err := filepath.EvalSymlinks(mainDir) + require.NoError(t, err) + expectedHooksDir := filepath.Join(resolvedMainDir, ".git", "hooks") + assert.Equal(t, expectedHooksDir, hooksDir, + "Install should resolve worktree gitDir to the main .git/hooks") + + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + content, err := os.ReadFile(filepath.Join(expectedHooksDir, name)) + require.NoError(t, err, "hook %s should exist in main .git/hooks", name) + assert.Contains(t, string(content), hooks.HookMarker) + + _, err = os.Stat(filepath.Join(worktreeGitDir, "hooks", name)) + assert.True(t, os.IsNotExist(err), + "hook %s should NOT be written to worktree-private hooks dir", name) + } +} diff --git a/app/cli/internal/trace/hooks/hooks.go b/app/cli/internal/trace/hooks/hooks.go new file mode 100644 index 000000000..e2027e996 --- /dev/null +++ b/app/cli/internal/trace/hooks/hooks.go @@ -0,0 +1,269 @@ +// +// 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 hooks + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + // HookMarker is the comment line embedded in managed hook scripts. + HookMarker = "# chainloop-trace-managed" + hookBackupSuffix = ".chainloop-backup" +) + +// managedHooks defines every git hook that Install creates. +// Order matters: hooks are installed in this order. +var managedHooks = []hookDef{ + {"commit-msg", true}, + {"post-commit", false}, + {"pre-push", false}, +} + +type hookDef struct { + // name is both the git hook filename and the "chainloop trace hook git " subcommand. + name string + passArgs bool +} + +// selectedHooks returns managedHooks with pre-push filtered out when +// skipPrePush is true. +func selectedHooks(skipPrePush bool) []hookDef { + if !skipPrePush { + return managedHooks + } + out := make([]hookDef, 0, len(managedHooks)) + for _, h := range managedHooks { + if h.name == "pre-push" { + continue + } + out = append(out, h) + } + + return out +} + +// IsInstalled reports whether the managed hooks are present and carry +// the chainloop marker. Pass skipPrePush=true to exclude pre-push from +// the check, matching what trace run installs. +// +// gitDir may point at a per-worktree gitdir; the common hooks dir is +// resolved internally (see resolveHooksDir). +func IsInstalled(gitDir string, skipPrePush bool) bool { + hooksDir, err := resolveHooksDir(gitDir) + if err != nil { + return false + } + + for _, h := range selectedHooks(skipPrePush) { + content, err := os.ReadFile(filepath.Join(hooksDir, h.name)) + if err != nil || !strings.Contains(string(content), HookMarker) { + return false + } + } + + return true +} + +// hookContent generates a hook script for the given command. +// When passArgs is true, "$@" is appended so the hook receives its positional arguments +// (e.g. commit-msg receives the message file path as $1). +func hookContent(hookCmd string, passArgs bool) string { + args := "" + if passArgs { + args = ` "$@"` + } + + // The trailing exit 0 keeps tracing from ever blocking a commit or push: + // without it a missing chainloop binary makes sh exit 127 and git aborts. + return fmt.Sprintf("#!/bin/sh\n%s\nchainloop trace hook git %s%s\nexit 0\n", HookMarker, hookCmd, args) +} + +// hookContentWithChain generates a hook script that chains to a backup. +func hookContentWithChain(hookCmd, backupPath string, passArgs bool) string { + args := "" + if passArgs { + args = ` "$@"` + } + + // exec makes the chained hook's status the hook's status. The trailing + // exit 0 covers a backup that lost its executable bit, where the && list + // would otherwise fail the hook (see hookContent). + return fmt.Sprintf("#!/bin/sh\n%s\nchainloop trace hook git %s%s\n[ -x \"%s\" ] && exec \"%s\" \"$@\"\nexit 0\n", + HookMarker, hookCmd, args, backupPath, backupPath) +} + +// Install installs git hooks for trace automation. gitDir may be either +// the main repo's .git or a per-worktree gitdir (.git/worktrees/); +// the resolved hooks directory is returned so callers can log it. +// +// Hooks are always written to the *common* hooks dir, since git only reads +// hooks/ from the shared .git/ even when invoked from a linked worktree. +// +// Pass skipPrePush=true to install only commit-msg and post-commit; used +// by trace run, which drives the attestation push itself. +func Install(gitDir string, skipPrePush bool) (string, error) { + hooksDir, err := resolveHooksDir(gitDir) + if err != nil { + return "", err + } + if err := os.MkdirAll(hooksDir, 0755); err != nil { + return "", fmt.Errorf("create hooks directory: %w", err) + } + + for _, h := range selectedHooks(skipPrePush) { + hookPath := filepath.Join(hooksDir, h.name) + backupPath := hookPath + hookBackupSuffix + + if err := installSingleHook(hookPath, backupPath, h.name, h.passArgs); err != nil { + return "", fmt.Errorf("install %s hook: %w", h.name, err) + } + } + + return hooksDir, nil +} + +func installSingleHook(hookPath, backupPath, hookCmd string, passArgs bool) error { + existing, err := os.ReadFile(hookPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read existing hook: %w", err) + } + + // Lstat: a dangling symlink still occupies backupPath and must not be + // renamed over. + _, statErr := os.Lstat(backupPath) + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("check hook backup: %w", statErr) + } + backupExists := statErr == nil + + if err == nil && !strings.Contains(string(existing), HookMarker) { + // Foreign hook, back it up and chain. An existing backup is the user's + // original hook, saved by an install whose uninstall never restored it, + // so it must never be overwritten. + if backupExists { + return fmt.Errorf("backup %s already exists; restore or remove it before installing", backupPath) + } + if err := os.Rename(hookPath, backupPath); err != nil { + return fmt.Errorf("backup existing hook: %w", err) + } + backupExists = true + } + + // Preserve chaining if a backup exists (idempotent reinstall) + if backupExists { + //nolint:gosec // git only runs hooks that are executable, so 0600 would silently disable them + return os.WriteFile(hookPath, []byte(hookContentWithChain(hookCmd, backupPath, passArgs)), 0755) + } + + //nolint:gosec // git only runs hooks that are executable, so 0600 would silently disable them + return os.WriteFile(hookPath, []byte(hookContent(hookCmd, passArgs)), 0755) +} + +// Uninstall removes git hooks installed by trace. gitDir semantics match +// Install. +func Uninstall(gitDir string) (string, error) { + hooksDir, err := resolveHooksDir(gitDir) + if err != nil { + return "", err + } + + for _, h := range managedHooks { + hookPath := filepath.Join(hooksDir, h.name) + backupPath := hookPath + hookBackupSuffix + + if err := uninstallSingleHook(hookPath, backupPath); err != nil { + return "", fmt.Errorf("uninstall %s hook: %w", h.name, err) + } + } + + return hooksDir, nil +} + +// resolveHooksDir returns the hooks directory git will actually read from, +// given a (possibly worktree-private) gitDir. In a linked worktree, gitDir +// is .git/worktrees/, but git classifies hooks/ as a common path: +// hooks placed under the per-worktree dir are silently ignored. The +// commondir pointer file inside the worktree-private dir tells us where +// the shared .git lives. +func resolveHooksDir(gitDir string) (string, error) { + commonDir, err := resolveCommonDir(gitDir) + if err != nil { + return "", err + } + + return filepath.Join(commonDir, "hooks"), nil +} + +// resolveCommonDir reads the commondir pointer file that git writes inside +// linked worktrees and returns the resolved common gitdir. If the file is +// absent (main checkout), gitDir is returned unchanged. +func resolveCommonDir(gitDir string) (string, error) { + commonDirFile := filepath.Join(gitDir, "commondir") + data, err := os.ReadFile(commonDirFile) + if errors.Is(err, os.ErrNotExist) { + return gitDir, nil + } + if err != nil { + return "", fmt.Errorf("read commondir file: %w", err) + } + + commonDir := strings.TrimSpace(string(data)) + if commonDir == "" { + return "", fmt.Errorf("commondir file is empty: %s", commonDirFile) + } + + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(gitDir, commonDir) + } + + commonDir = filepath.Clean(commonDir) + if _, err := os.Stat(commonDir); err != nil { + return "", fmt.Errorf("commondir path does not exist: %w", err) + } + + return commonDir, nil +} + +func uninstallSingleHook(hookPath, backupPath string) error { + content, err := os.ReadFile(hookPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read hook: %w", err) + } + + if !strings.Contains(string(content), HookMarker) { + // Not our hook, leave it alone + return nil + } + + if err := os.Remove(hookPath); err != nil { + return err + } + + // Restore backup if it exists + if err := os.Rename(backupPath, hookPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + return nil +} diff --git a/app/cli/internal/trace/hooks/hooks_test.go b/app/cli/internal/trace/hooks/hooks_test.go new file mode 100644 index 000000000..eaa77828e --- /dev/null +++ b/app/cli/internal/trace/hooks/hooks_test.go @@ -0,0 +1,321 @@ +// +// 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 hooks + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstall(t *testing.T) { + t.Run("installs hooks in empty directory", func(t *testing.T) { + gitDir := t.TempDir() + + _, err := Install(gitDir, false) + require.NoError(t, err) + + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + hookPath := filepath.Join(gitDir, "hooks", name) + content, err := os.ReadFile(hookPath) + require.NoError(t, err) + assert.Contains(t, string(content), HookMarker) + assert.Contains(t, string(content), "chainloop trace hook git "+name) + + info, err := os.Stat(hookPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0755), info.Mode().Perm()) + } + + // commit-msg must forward arguments so the message file path reaches the handler + commitMsgContent, err := os.ReadFile(filepath.Join(gitDir, "hooks", "commit-msg")) + require.NoError(t, err) + assert.Contains(t, string(commitMsgContent), `"$@"`) + + // post-commit and pre-push must NOT forward arguments + for _, name := range []string{"post-commit", "pre-push"} { + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", name)) + require.NoError(t, err) + assert.NotContains(t, string(content), `"$@"`) + } + }) + + t.Run("backs up existing foreign hooks", func(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + foreignContent := "#!/bin/sh\necho existing hook\n" + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"), []byte(foreignContent), 0600)) + + _, err := Install(gitDir, false) + require.NoError(t, err) + + // Backup should exist + backupContent, err := os.ReadFile(filepath.Join(hooksDir, "post-commit"+hookBackupSuffix)) + require.NoError(t, err) + assert.Equal(t, foreignContent, string(backupContent)) + + // New hook should chain to backup + hookContent, err := os.ReadFile(filepath.Join(hooksDir, "post-commit")) + require.NoError(t, err) + assert.Contains(t, string(hookContent), HookMarker) + assert.Contains(t, string(hookContent), hookBackupSuffix) + }) + + t.Run("overwrites existing chainloop hooks", func(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + // Write a chainloop hook + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte("#!/bin/sh\n"+HookMarker+"\nold version"), 0600)) + + _, err := Install(gitDir, false) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(hooksDir, "pre-push")) + require.NoError(t, err) + assert.Contains(t, string(content), HookMarker) + assert.NotContains(t, string(content), "old version") + + // No backup should exist + _, err = os.Stat(filepath.Join(hooksDir, "pre-push"+hookBackupSuffix)) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("writes to common dir when given a worktree gitdir", func(t *testing.T) { + // Simulate a worktree layout: main/.git is the common dir, and + // main/.git/worktrees/wt is the per-worktree gitdir with a + // commondir pointer back to ../... + tmpDir := t.TempDir() + commonDir := filepath.Join(tmpDir, "main", ".git") + require.NoError(t, os.MkdirAll(commonDir, 0o755)) + + worktreeGitDir := filepath.Join(commonDir, "worktrees", "wt") + require.NoError(t, os.MkdirAll(worktreeGitDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(worktreeGitDir, "commondir"), + []byte("../..\n"), + 0o600, + )) + + hooksDir, err := Install(worktreeGitDir, false) + require.NoError(t, err) + assert.Equal(t, filepath.Join(commonDir, "hooks"), hooksDir) + + // Hooks must land under the common dir, not the worktree-private dir. + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + content, err := os.ReadFile(filepath.Join(commonDir, "hooks", name)) + require.NoError(t, err, "hook %s should exist in common .git/hooks", name) + assert.Contains(t, string(content), HookMarker) + + _, err = os.Stat(filepath.Join(worktreeGitDir, "hooks", name)) + assert.True(t, os.IsNotExist(err), + "hook %s must NOT be written to worktree-private hooks dir", name) + } + }) +} + +func TestInstallSkipPrePush(t *testing.T) { + t.Run("installs commit-msg and post-commit only", func(t *testing.T) { + gitDir := t.TempDir() + + _, err := Install(gitDir, true) + require.NoError(t, err) + + for _, name := range []string{"commit-msg", "post-commit"} { + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", name)) + require.NoError(t, err, "hook %s should be installed", name) + assert.Contains(t, string(content), HookMarker) + } + + _, err = os.Stat(filepath.Join(gitDir, "hooks", "pre-push")) + assert.True(t, os.IsNotExist(err), "pre-push must not be installed when skipPrePush=true") + }) + + t.Run("IsInstalled with skipPrePush ignores missing pre-push", func(t *testing.T) { + gitDir := t.TempDir() + + _, err := Install(gitDir, true) + require.NoError(t, err) + + assert.True(t, IsInstalled(gitDir, true), "should be installed when only checking the trace-run subset") + assert.False(t, IsInstalled(gitDir, false), "default check requires pre-push and should fail") + }) +} + +func TestInstallRefusesToClobberBackup(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + // A leftover backup (crashed uninstall) plus a foreign hook: the backup is + // the user's original and must survive. + original := "#!/bin/sh\necho original\n" + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"+hookBackupSuffix), []byte(original), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"), []byte("#!/bin/sh\necho newer\n"), 0600)) + + _, err := Install(gitDir, false) + require.Error(t, err) + + content, err := os.ReadFile(filepath.Join(hooksDir, "post-commit"+hookBackupSuffix)) + require.NoError(t, err) + assert.Equal(t, original, string(content)) +} + +// TestHookScriptsExitZero runs each generated hook with an empty PATH (no +// chainloop binary, as in GUI git clients) and asserts it still succeeds: +// a non-zero hook aborts the user's commit. +func TestHookScriptsExitZero(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + // post-commit gets a foreign hook so the chained variant is covered too, + // with its executable bit cleared to exercise the failing `&&` list. + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"), []byte("#!/bin/sh\nexit 0\n"), 0600)) + + _, err := Install(gitDir, false) + require.NoError(t, err) + + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + //nolint:gosec // the hook path is derived from t.TempDir(), not from user input + cmd := exec.Command("/bin/sh", filepath.Join(hooksDir, name), "msgfile") + cmd.Env = []string{"PATH="} + out, err := cmd.CombinedOutput() + assert.NoError(t, err, "hook %s must exit 0 without chainloop on PATH: %s", name, out) + } +} + +func TestUninstall(t *testing.T) { + t.Run("removes chainloop hooks", func(t *testing.T) { + gitDir := t.TempDir() + _, err := Install(gitDir, false) + require.NoError(t, err) + + _, err = Uninstall(gitDir) + require.NoError(t, err) + + for _, name := range []string{"commit-msg", "post-commit", "pre-push"} { + _, err := os.Stat(filepath.Join(gitDir, "hooks", name)) + assert.True(t, os.IsNotExist(err)) + } + }) + + t.Run("restores backup hooks", func(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + // Install foreign hook, then chainloop hook on top + foreignContent := "#!/bin/sh\necho foreign\n" + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "post-commit"), []byte(foreignContent), 0600)) + _, err := Install(gitDir, false) + require.NoError(t, err) + + // Uninstall + _, err = Uninstall(gitDir) + require.NoError(t, err) + + // Foreign hook should be restored + content, err := os.ReadFile(filepath.Join(hooksDir, "post-commit")) + require.NoError(t, err) + assert.Equal(t, foreignContent, string(content)) + }) + + t.Run("leaves foreign hooks alone", func(t *testing.T) { + gitDir := t.TempDir() + hooksDir := filepath.Join(gitDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + + foreignContent := "#!/bin/sh\necho untouched\n" + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "pre-push"), []byte(foreignContent), 0600)) + + _, err := Uninstall(gitDir) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(hooksDir, "pre-push")) + require.NoError(t, err) + assert.Equal(t, foreignContent, string(content)) + }) + + t.Run("noop when no hooks exist", func(t *testing.T) { + gitDir := t.TempDir() + _, err := Uninstall(gitDir) + assert.NoError(t, err) + }) +} + +func TestResolveCommonDir(t *testing.T) { + t.Run("absent commondir file returns gitDir unchanged", func(t *testing.T) { + gitDir := t.TempDir() + + resolved, err := resolveCommonDir(gitDir) + require.NoError(t, err) + assert.Equal(t, gitDir, resolved) + }) + + t.Run("absolute commondir path", func(t *testing.T) { + tmpDir := t.TempDir() + commonDir := filepath.Join(tmpDir, "main", ".git") + require.NoError(t, os.MkdirAll(commonDir, 0o755)) + + worktreeGitDir := filepath.Join(commonDir, "worktrees", "wt") + require.NoError(t, os.MkdirAll(worktreeGitDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(worktreeGitDir, "commondir"), + []byte(commonDir+"\n"), + 0o600, + )) + + resolved, err := resolveCommonDir(worktreeGitDir) + require.NoError(t, err) + assert.Equal(t, commonDir, resolved) + }) + + t.Run("relative commondir path", func(t *testing.T) { + tmpDir := t.TempDir() + commonDir := filepath.Join(tmpDir, "main", ".git") + require.NoError(t, os.MkdirAll(commonDir, 0o755)) + + // git writes "../.." here: from .git/worktrees/ up to main .git. + worktreeGitDir := filepath.Join(commonDir, "worktrees", "wt") + require.NoError(t, os.MkdirAll(worktreeGitDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(worktreeGitDir, "commondir"), + []byte("../..\n"), + 0o600, + )) + + resolved, err := resolveCommonDir(worktreeGitDir) + require.NoError(t, err) + assert.Equal(t, commonDir, resolved) + }) + + t.Run("empty commondir file is rejected", func(t *testing.T) { + gitDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("\n"), 0o600)) + + _, err := resolveCommonDir(gitDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "commondir file is empty") + }) +} diff --git a/app/cli/internal/trace/opencode/export_integration_test.go b/app/cli/internal/trace/opencode/export_integration_test.go new file mode 100644 index 000000000..f8e597b3b --- /dev/null +++ b/app/cli/internal/trace/opencode/export_integration_test.go @@ -0,0 +1,71 @@ +//go:build integration + +// +// 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 opencode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRealExportViaFile verifies that CopySessionData captures the full +// export output via file redirect, avoiding the pipe truncation bug where +// `opencode export` (Node.js) doesn't flush stdout to pipes properly. +func TestRealExportViaFile(t *testing.T) { + const sessionID = "ses_0cbfb6771ffeqmW0YTMB4Sp0xN" + + store := state.NewGitStore(t.TempDir()) + provider := New() + err := provider.CopySessionData(store, "/repo", sessionID) + require.NoError(t, err) + + rawDir := store.RawSessionDir() + path := filepath.Join(rawDir, state.SanitizeID(sessionID)+".jsonl") + data, err := os.ReadFile(path) + require.NoError(t, err) + + // Must exceed the 64 KiB pipe truncation limit. + assert.Greater(t, len(data), 65536, "file redirect should capture full output") + t.Logf("Captured %d bytes via file redirect", len(data)) + + var export exportData + require.NoError(t, json.Unmarshal(data, &export)) + assert.Equal(t, sessionID, export.Info.ID) + assert.NotEmpty(t, export.Messages) + t.Logf("Session: %s, messages: %d", export.Info.Title, len(export.Messages)) + + // Verify it parses all the way through the provider. + result, err := provider.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: sessionID, + }) + require.NoError(t, err) + assert.Equal(t, "opencode", result.Data.Agent.Name) + assert.Equal(t, sessionID, result.Data.Session.ID) + assert.NotEmpty(t, result.Data.Conversation.TotalMessages) + t.Logf("Parsed: %d messages, %d tool invocations", + result.Data.Conversation.TotalMessages, + result.Data.ToolsUsed.TotalInvocations) +} diff --git a/app/cli/internal/trace/opencode/golden_test.go b/app/cli/internal/trace/opencode/golden_test.go new file mode 100644 index 000000000..3a9618f2e --- /dev/null +++ b/app/cli/internal/trace/opencode/golden_test.go @@ -0,0 +1,63 @@ +// +// 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 opencode + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPluginGoldenFiles compares the generated plugin output against the +// committed golden files in testdata/. Run with UPDATE_GOLDEN=1 to +// regenerate them after an intentional template change: +// +// UPDATE_GOLDEN=1 go test ./app/cli/internal/trace/opencode/ -run TestPluginGoldenFiles +func TestPluginGoldenFiles(t *testing.T) { + p := New() + + cases := []struct { + name string + install func(string) error + golden string + }{ + {"full", p.InstallHooks, "testdata/plugin_full.ts"}, + {"tracerun", p.InstallHooksForTraceRun, "testdata/plugin_tracerun.ts"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, tc.install(repoRoot)) + + generated, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + if os.Getenv("UPDATE_GOLDEN") == "1" { + require.NoError(t, os.WriteFile(tc.golden, generated, 0600)) + return + } + + expected, err := os.ReadFile(tc.golden) + require.NoError(t, err, "golden file missing; run UPDATE_GOLDEN=1 to generate") + assert.Equal(t, string(expected), string(generated), + "generated plugin does not match golden file %s; run UPDATE_GOLDEN=1 to update", tc.golden) + }) + } +} diff --git a/app/cli/internal/trace/opencode/hooks.go b/app/cli/internal/trace/opencode/hooks.go new file mode 100644 index 000000000..b86929e25 --- /dev/null +++ b/app/cli/internal/trace/opencode/hooks.go @@ -0,0 +1,293 @@ +// +// 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 opencode + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" +) + +const ( + // settingsFile is the project-local opencode plugin file path. + settingsFile = ".opencode/plugins/chainloop-trace.ts" +) + +// fileWritingTools is the single source of truth for opencode tool names +// that modify files. The TypeScript plugin's array is generated from this +// slice (see {{FileWritingToolsArray}}) so the plugin and IsFileWritingTool +// can never drift. +var fileWritingTools = []string{"edit", "write", "apply_patch"} + +// commandTools are opencode tool names that run shell commands. Their file +// changes are captured via a before/after working-tree snapshot (they have no +// single file-path argument), so the plugin fires the pre/post-tool-use hooks +// for them without a file_path and the handler branches on the tool kind. +var commandTools = []string{"bash"} + +// bt is a single backtick, used to splice backticks into Go raw string literals. +const bt = "`" + +// pluginTemplate is the TypeScript plugin written to .opencode/plugins/chainloop-trace.ts. +// It fires chainloop trace hook subcommands on session lifecycle and tool events. +// The {{SessionEndBlock}} placeholder is replaced with the session.deleted handler +// for full install, or removed entirely for trace-run install. +// +// Go raw string literals cannot contain backticks, so the TypeScript template +// literal (Bun shell) lines are split at backtick boundaries and spliced with bt. +const pluginTemplate = `import type { Plugin } from "@opencode-ai/plugin" + +export const ChainloopTrace: Plugin = async ({ $ }) => { + const fileWritingTools = {{FileWritingToolsArray}} + const commandTools = {{CommandToolsArray}} + + function filePathsFromArgs(args: any): string[] { + if (args?.filePath) return [args.filePath] + if (args?.path) return [args.path] + if (args?.patchText) return parsePatchPaths(args.patchText) + return [] + } + + // parsePatchPaths extracts affected file paths from an apply_patch + // patchText payload. Each section starts with *** Add File:, *** Update + // File:, or *** Delete File: followed by the path. Paths are deduplicated + // while preserving first-seen order. + function parsePatchPaths(patchText: string): string[] { + const paths: string[] = [] + const seen = new Set() + const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let m + while ((m = re.exec(patchText)) !== null) { + const p = m[1].trim() + if (p && !seen.has(p)) { + seen.add(p) + paths.push(p) + } + } + return paths + } + + // fire-and-forget: tracing must never block tool execution. If chainloop + // is unavailable or errors, log to stderr and move on. + async function fire(event: string, payload: Record) { + const json = JSON.stringify(payload) + try { + await $` + bt + `echo ${json} | chainloop trace hook opencode ${event}` + bt + ` + } catch (err) { + console.error(` + bt + `chainloop-trace: ${event} hook failed: ${err}` + bt + `) + } + } + + return { + event: async ({ event }) => { + if (event.type === "session.created") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + } +{{SessionEndBlock}} + }, + "tool.execute.before": async (input, output) => { + if (commandTools.includes(input.tool)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(output.args)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + file_path: fp, + }) + } + }, + "tool.execute.after": async (input) => { + if (commandTools.includes(input.tool)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(input.args)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + file_path: fp, + }) + } + }, + } +} +` + +// sessionDeletedHandler is the session.deleted block inserted for full install. +// session.idle fires at the end of every agent turn and would prematurely end +// the trace session; session.deleted fires only when the session is destroyed. +const sessionDeletedHandler = ` if (event.type === "session.deleted") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-end", { session_id: sessionID, hook_event_name: "session.deleted" }) + }` + +// SettingsFile returns the absolute path to the plugin file for the given repo. +func (p *Provider) SettingsFile(repoRoot string) string { + return filepath.Join(repoRoot, settingsFile) +} + +// InstallHooks writes the opencode plugin file with all hooks including session.deleted. +func (p *Provider) InstallHooks(repoRoot string) error { + return p.writePluginFile(repoRoot, true) +} + +// InstallHooksForTraceRun writes the plugin file without the session.deleted handler; +// trace run drives end-of-session itself. +func (p *Provider) InstallHooksForTraceRun(repoRoot string) error { + return p.writePluginFile(repoRoot, false) +} + +func (p *Provider) writePluginFile(repoRoot string, includeSessionEnd bool) error { + pluginPath := filepath.Join(repoRoot, settingsFile) + + content := pluginTemplate + content = strings.Replace(content, "{{FileWritingToolsArray}}", toolsArrayLiteral(fileWritingTools), 1) + content = strings.Replace(content, "{{CommandToolsArray}}", toolsArrayLiteral(commandTools), 1) + if includeSessionEnd { + content = strings.Replace(content, "{{SessionEndBlock}}", sessionDeletedHandler, 1) + } else { + content = strings.Replace(content, "{{SessionEndBlock}}\n", "", 1) + } + + if err := os.MkdirAll(filepath.Dir(pluginPath), 0755); err != nil { + return fmt.Errorf("create plugin directory: %w", err) + } + + if existing, err := os.ReadFile(pluginPath); err == nil && string(existing) == content { + return nil + } + + return os.WriteFile(pluginPath, []byte(content), 0600) +} + +// toolsArrayLiteral renders a tool-name slice as a TypeScript array literal so +// the plugin and the Go IsFileWritingTool/IsCommandTool predicates share one +// source of truth. json.Marshal output is valid TypeScript for a []string. +func toolsArrayLiteral(tools []string) string { + b, _ := json.Marshal(tools) + return string(b) +} + +// UninstallHooks removes the plugin file. The plugin file is entirely +// chainloop-owned, so we remove it without preserving content. +func (p *Provider) UninstallHooks(repoRoot string) error { + pluginPath := filepath.Join(repoRoot, settingsFile) + + err := os.Remove(pluginPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err +} + +// maxHookPayloadBytes caps hook payload reads to defend against runaway or +// malformed payloads. +const maxHookPayloadBytes = 16 * 1024 * 1024 + +// ReadHookInput reads and parses the opencode hook JSON from the given reader. +// The plugin pipes JSON to stdin with fields: session_id, hook_event_name, +// tool_name, file_path. +func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { + data, err := io.ReadAll(io.LimitReader(r, maxHookPayloadBytes)) + if err != nil { + return nil, err + } + + var raw struct { + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + FilePath string `json:"file_path"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + return &trace.HookInput{ + SessionID: raw.SessionID, + HookEventName: raw.HookEventName, + ToolName: raw.ToolName, + FilePath: raw.FilePath, + }, nil +} + +// patchFileRe matches *** Add File:, *** Update File:, or *** Delete File: +// section headers in an apply_patch patchText payload. It mirrors the +// regex used by the TypeScript plugin's parsePatchPaths function so the +// parsing logic is testable from Go (the TS plugin splits per-file before +// the Go side ever sees patchText). +var patchFileRe = regexp.MustCompile(`(?m)^\*\*\* (?:Add|Update|Delete) File: (.+)$`) + +// parsePatchPaths extracts affected file paths from an apply_patch +// patchText payload. Each section starts with *** Add File:, *** Update +// File:, or *** Delete File: followed by the path. Paths are deduplicated +// while preserving first-seen order. Both absolute and repository-relative +// paths are returned verbatim — the caller is responsible for any +// normalization. +func parsePatchPaths(patchText string) []string { + var paths []string + seen := make(map[string]bool) + for _, m := range patchFileRe.FindAllStringSubmatch(patchText, -1) { + p := strings.TrimSpace(m[1]) + if p == "" || seen[p] { + continue + } + seen[p] = true + paths = append(paths, p) + } + return paths +} + +// IsFileWritingTool returns true if the named tool modifies files on disk. +func (p *Provider) IsFileWritingTool(toolName string) bool { + return slices.Contains(fileWritingTools, toolName) +} + +// IsCommandTool returns true if the named tool runs a shell command. +func (p *Provider) IsCommandTool(toolName string) bool { + return slices.Contains(commandTools, toolName) +} + +// opencodeBinaryAvailable checks whether the opencode CLI is on PATH. +func opencodeBinaryAvailable() bool { + _, err := exec.LookPath("opencode") + return err == nil +} diff --git a/app/cli/internal/trace/opencode/hooks_test.go b/app/cli/internal/trace/opencode/hooks_test.go new file mode 100644 index 000000000..1b2e43563 --- /dev/null +++ b/app/cli/internal/trace/opencode/hooks_test.go @@ -0,0 +1,319 @@ +// +// 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 opencode + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallHooksCreatesPluginFile(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooks(repoRoot)) + + pluginPath := filepath.Join(repoRoot, settingsFile) + data, err := os.ReadFile(pluginPath) + require.NoError(t, err) + + content := string(data) + assert.Contains(t, content, "chainloop trace hook opencode") + assert.Contains(t, content, "session.created") + assert.Contains(t, content, "session.deleted") + assert.Contains(t, content, "tool.execute.before") + assert.Contains(t, content, "tool.execute.after") + assert.Contains(t, content, `const fileWritingTools = ["edit","write","apply_patch"]`) +} + +func TestInstallHooksForTraceRunOmitsSessionEnd(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooksForTraceRun(repoRoot)) + + pluginPath := filepath.Join(repoRoot, settingsFile) + data, err := os.ReadFile(pluginPath) + require.NoError(t, err) + + content := string(data) + assert.Contains(t, content, "session.created") + assert.NotContains(t, content, "session.deleted", "trace run must not install session.deleted; trace run drives end-of-session itself") + assert.Contains(t, content, "tool.execute.before") + assert.Contains(t, content, "tool.execute.after") +} + +func TestInstallHooksIdempotent(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooks(repoRoot)) + firstStat, err := os.Stat(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + require.NoError(t, p.InstallHooks(repoRoot)) + secondStat, err := os.Stat(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + // File should not be rewritten when content is identical. + assert.Equal(t, firstStat.ModTime(), secondStat.ModTime()) +} + +func TestUninstallHooksRemovesPluginFile(t *testing.T) { + repoRoot := t.TempDir() + p := New() + + require.NoError(t, p.InstallHooks(repoRoot)) + require.NoError(t, p.UninstallHooks(repoRoot)) + + _, err := os.Stat(filepath.Join(repoRoot, settingsFile)) + assert.True(t, os.IsNotExist(err), "plugin file should be removed") +} + +func TestUninstallHooksNoopWhenFileMissing(t *testing.T) { + repoRoot := t.TempDir() + p := New() + assert.NoError(t, p.UninstallHooks(repoRoot)) +} + +func TestReadHookInputParsesValidInput(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":"abc-123","hook_event_name":"session.created","tool_name":"edit","file_path":"/some/file.go"}`) + p := New() + input, err := p.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Equal(t, "session.created", input.HookEventName) + assert.Equal(t, "edit", input.ToolName) + assert.Equal(t, "/some/file.go", input.FilePath) +} + +func TestReadHookInputApplyPatchSingleFile(t *testing.T) { + // apply_patch fires one hook per file, so each invocation still carries + // a single file_path — this is the shape the plugin emits after the fix. + r := bytes.NewBufferString(`{"session_id":"ses_apply_patch_test","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":"/tmp/trace-fixture/existing.txt"}`) + p := New() + input, err := p.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "ses_apply_patch_test", input.SessionID) + assert.Equal(t, "apply_patch", input.ToolName) + assert.Equal(t, "/tmp/trace-fixture/existing.txt", input.FilePath) +} + +func TestReadHookInputHandlesMissingFields(t *testing.T) { + r := bytes.NewBufferString(`{"session_id":"abc-123"}`) + p := New() + input, err := p.ReadHookInput(r) + require.NoError(t, err) + assert.Equal(t, "abc-123", input.SessionID) + assert.Empty(t, input.HookEventName) + assert.Empty(t, input.ToolName) + assert.Empty(t, input.FilePath) +} + +func TestReadHookInputReturnsErrorForInvalidJSON(t *testing.T) { + r := bytes.NewBufferString(`not json`) + p := New() + _, err := p.ReadHookInput(r) + assert.Error(t, err) +} + +func TestIsFileWritingTool(t *testing.T) { + p := New() + assert.True(t, p.IsFileWritingTool("edit")) + assert.True(t, p.IsFileWritingTool("write")) + assert.True(t, p.IsFileWritingTool("apply_patch")) + assert.False(t, p.IsFileWritingTool("read")) + assert.False(t, p.IsFileWritingTool("bash")) + assert.False(t, p.IsFileWritingTool("")) +} + +func TestIsCommandTool(t *testing.T) { + p := New() + assert.True(t, p.IsCommandTool("bash")) + assert.False(t, p.IsCommandTool("edit")) + assert.False(t, p.IsCommandTool("write")) + assert.False(t, p.IsCommandTool("")) +} + +func TestSettingsFile(t *testing.T) { + p := New() + assert.Equal(t, filepath.Join("/repo", settingsFile), p.SettingsFile("/repo")) +} + +func TestPluginTemplateHasNoUnreplacedPlaceholders(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + assert.NotContains(t, string(data), "{{SessionEndBlock}}") + assert.NotContains(t, string(data), "{{FileWritingToolsArray}}") +} + +func TestPluginTemplateFileWritingToolsMatchesGoSlice(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + + goLiteral, err := json.Marshal(fileWritingTools) + require.NoError(t, err) + assert.Contains(t, string(data), "const fileWritingTools = "+string(goLiteral)) +} + +func TestPluginTemplateContainsBacktickShellCommand(t *testing.T) { + // The plugin uses Bun's $ template literal for shell commands. Verify + // the backticks survived the Go raw string splice so the plugin is + // syntactically valid TypeScript. + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + assert.Contains(t, string(data), "$`echo") + assert.Contains(t, string(data), "chainloop trace hook opencode") +} + +func TestParsePatchPaths(t *testing.T) { + cases := []struct { + name string + patch string + expected []string + }{ + { + name: "single-file update (absolute path)", + patch: "*** Begin Patch\n" + + "*** Update File: /tmp/trace-fixture/existing.txt\n" + + "@@\n" + + "-old value\n" + + "+new value\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/existing.txt"}, + }, + { + name: "add file (absolute path)", + patch: "*** Begin Patch\n" + + "*** Add File: /tmp/trace-fixture/added.txt\n" + + "+created by the agent\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/added.txt"}, + }, + { + name: "delete file (absolute path)", + patch: "*** Begin Patch\n" + + "*** Delete File: /tmp/trace-fixture/deleted.txt\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/deleted.txt"}, + }, + { + name: "multi-file patch preserving order", + patch: "*** Begin Patch\n" + + "*** Update File: /tmp/trace-fixture/first.txt\n" + + "@@\n" + + "-before\n" + + "+after\n" + + "*** Add File: /tmp/trace-fixture/second.txt\n" + + "+new file\n" + + "*** Delete File: /tmp/trace-fixture/third.txt\n" + + "*** End Patch\n", + expected: []string{ + "/tmp/trace-fixture/first.txt", + "/tmp/trace-fixture/second.txt", + "/tmp/trace-fixture/third.txt", + }, + }, + { + name: "repeated file sections are deduplicated", + patch: "*** Begin Patch\n" + + "*** Update File: /tmp/trace-fixture/dup.txt\n" + + "@@\n" + + "-a\n" + + "+b\n" + + "*** Update File: /tmp/trace-fixture/dup.txt\n" + + "@@\n" + + "-c\n" + + "+d\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/dup.txt"}, + }, + { + name: "repository-relative paths", + patch: "*** Begin Patch\n" + + "*** Update File: src/main.go\n" + + "@@\n" + + "-old\n" + + "+new\n" + + "*** Add File: src/new.go\n" + + "+package main\n" + + "*** End Patch\n", + expected: []string{"src/main.go", "src/new.go"}, + }, + { + name: "empty patchText returns nil", + patch: "", + expected: nil, + }, + { + name: "patch with no file sections returns nil", + patch: "*** Begin Patch\n" + + "*** End Patch\n", + expected: nil, + }, + { + name: "paths with surrounding whitespace are trimmed", + patch: "*** Begin Patch\n" + + "*** Update File: /tmp/trace-fixture/spaced.txt \n" + + "@@\n" + + "-a\n" + + "+b\n" + + "*** End Patch\n", + expected: []string{"/tmp/trace-fixture/spaced.txt"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parsePatchPaths(tc.patch) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestPluginTemplateContainsPatchParsing(t *testing.T) { + repoRoot := t.TempDir() + p := New() + require.NoError(t, p.InstallHooks(repoRoot)) + + data, err := os.ReadFile(filepath.Join(repoRoot, settingsFile)) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "filePathsFromArgs") + assert.Contains(t, content, "parsePatchPaths") + assert.Contains(t, content, "patchText") + assert.Contains(t, content, "Add|Update|Delete") + // Verify the plugin loops over paths rather than sending a single path. + assert.Contains(t, content, "for (const fp of filePathsFromArgs") +} diff --git a/app/cli/internal/trace/opencode/parse.go b/app/cli/internal/trace/opencode/parse.go new file mode 100644 index 000000000..0a448ccee --- /dev/null +++ b/app/cli/internal/trace/opencode/parse.go @@ -0,0 +1,256 @@ +// +// 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 opencode + +import ( + "encoding/json" + "fmt" + "math" + "os" + "sort" + "time" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// unknownValue stands in for a model or tool name the export didn't carry. +const unknownValue = "unknown" + +// parseExport reads and parses the opencode export JSON at path into +// structured evidence. Unknown fields are ignored. +func parseExport(path string) (*aicodingsession.Evidence, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read export file: %w", err) + } + + var export exportData + if err := json.Unmarshal(data, &export); err != nil { + return nil, fmt.Errorf("parse export JSON: %w", err) + } + + return buildEvidence(&export), nil +} + +// buildEvidence converts the parsed export into an aicodingsession.Evidence. +func buildEvidence(export *exportData) *aicodingsession.Evidence { + var totalInput, totalOutput, totalCacheRead, totalCacheWrite int + var totalCost float64 + modelsSeen := make(map[string]int) + toolCounts := make(map[string]int) + var userMessages, assistantMessages int + var providerID string + + for _, msg := range export.Messages { + switch msg.Info.Role { + case "user": + userMessages++ + case "assistant": + assistantMessages++ + if msg.Info.ModelID != "" { + modelsSeen[msg.Info.ModelID]++ + } + // Provider is derived from the first assistant message that carries one. + if providerID == "" && msg.Info.ProviderID != "" { + providerID = msg.Info.ProviderID + } + if msg.Info.Tokens != nil { + totalInput += msg.Info.Tokens.Input + totalOutput += msg.Info.Tokens.Output + totalCacheRead += msg.Info.Tokens.Cache.Read + totalCacheWrite += msg.Info.Tokens.Cache.Write + } + totalCost += msg.Info.Cost + } + + // Count completed tool invocations from tool parts. + for _, part := range msg.Parts { + if part.Type == "tool" && part.State != nil && part.State.Status == "completed" { + name := part.Tool + if name == "" { + name = unknownValue + } + toolCounts[name]++ + } + } + } + + // Prefer session-level cost/tokens when available (opencode computes + // these natively); fall back to per-message aggregation. + if export.Info.Cost != nil { + totalCost = *export.Info.Cost + } + if export.Info.Tokens != nil { + totalInput = export.Info.Tokens.Input + totalOutput = export.Info.Tokens.Output + totalCacheRead = export.Info.Tokens.Cache.Read + totalCacheWrite = export.Info.Tokens.Cache.Write + } + + primaryModel := unknownValue + maxCount := 0 + for model, count := range modelsSeen { + if count > maxCount { + primaryModel = model + maxCount = count + } + } + + modelsUsed := make([]string, 0, len(modelsSeen)) + for model := range modelsSeen { + modelsUsed = append(modelsUsed, model) + } + sort.Strings(modelsUsed) + + // Timestamps: opencode uses epoch milliseconds. + startedAt := formatEpochMs(export.Info.Time.Created) + endedAt := formatEpochMs(export.Info.Time.Updated) + var durationSeconds int + if export.Info.Time.Created > 0 && export.Info.Time.Updated > 0 { + durationSeconds = int((export.Info.Time.Updated - export.Info.Time.Created) / 1000) + } + + toolSummary := make([]aicodingsession.ToolSummary, 0, len(toolCounts)) + totalInvocations := 0 + for name, count := range toolCounts { + toolSummary = append(toolSummary, aicodingsession.ToolSummary{ToolName: name, InvocationCount: count}) + totalInvocations += count + } + sort.Slice(toolSummary, func(i, j int) bool { + if toolSummary[i].InvocationCount != toolSummary[j].InvocationCount { + return toolSummary[i].InvocationCount > toolSummary[j].InvocationCount + } + return toolSummary[i].ToolName < toolSummary[j].ToolName + }) + + // Round cost to 4 decimal places to match Claude provider's precision. + totalCost = math.Round(totalCost*10000) / 10000 + + // Raw session: convert each opencode message into a frontend-compatible + // entry (one json.RawMessage per message) so the conversation timeline + // can render individual messages. The entry shape mirrors Claude's + // JSONL format: {type, uuid, timestamp, message: {role, content}}. + rawSession := map[string][]json.RawMessage{ + "main": buildRawSessionEntries(export.Messages), + } + + return aicodingsession.NewEvidence(aicodingsession.Data{ + SchemaVersion: "v1", + Agent: aicodingsession.Agent{ + Name: Name, + Version: export.Info.Version, + }, + Session: aicodingsession.Session{ + ID: export.Info.ID, + Slug: export.Info.Slug, + StartedAt: startedAt, + EndedAt: endedAt, + DurationSeconds: durationSeconds, + }, + Model: &aicodingsession.Model{ + Primary: primaryModel, + Provider: providerID, + ModelsUsed: modelsUsed, + }, + Usage: &aicodingsession.Usage{ + InputTokens: totalInput, + OutputTokens: totalOutput, + TotalTokens: totalInput + totalOutput, + CacheReadInputTokens: totalCacheRead, + CacheCreationInputTokens: totalCacheWrite, + EstimatedCostUSD: totalCost, + }, + ToolsUsed: &aicodingsession.ToolsUsed{ + Summary: toolSummary, + TotalInvocations: totalInvocations, + }, + Conversation: &aicodingsession.Conversation{ + TotalMessages: userMessages + assistantMessages, + UserMessages: userMessages, + AssistantMessages: assistantMessages, + }, + RawSession: rawSession, + }) +} + +// formatEpochMs converts an opencode epoch-ms timestamp to RFC3339; +// returns "" for non-positive values. +func formatEpochMs(ms float64) string { + if ms <= 0 { + return "" + } + t := time.UnixMilli(int64(ms)).UTC() + return t.Format(time.RFC3339) +} + +// buildRawSessionEntries converts opencode messages into the frontend-compatible +// RawSessionEntry format (one entry per message). Each entry carries the +// message role, ID, timestamp, and content blocks (text and tool_use) so the +// ConversationTimeline can render the full transcript. +func buildRawSessionEntries(messages []messageEntry) []json.RawMessage { + entries := make([]json.RawMessage, 0, len(messages)) + for _, msg := range messages { + entry := trace.RawSessionEntry{ + Type: msg.Info.Role, + UUID: msg.Info.ID, + Timestamp: msgTimeRFC3339(msg.Info.Time), + Message: trace.RawSessionMessage{ + Role: msg.Info.Role, + Model: msg.Info.ModelID, + }, + } + + var blocks []any + for _, p := range msg.Parts { + switch p.Type { + case "text": + if p.Text != "" { + blocks = append(blocks, trace.RawSessionTextBlock{Type: "text", Text: p.Text}) + } + case "tool": + if p.State == nil || p.State.Status != "completed" { + continue + } + toolName := p.Tool + if toolName == "" { + toolName = unknownValue + } + blocks = append(blocks, trace.RawSessionToolUseBlock{ + Type: "tool_use", + ID: p.ID, + Name: toolName, + Input: p.State.Input, + }) + } + } + + if len(blocks) > 0 { + content, _ := json.Marshal(blocks) + entry.Message.Content = content + } + + entries = append(entries, trace.MustRawSessionEntry(entry)) + } + return entries +} + +func msgTimeRFC3339(t *msgTime) string { + if t == nil || t.Created <= 0 { + return "" + } + return formatEpochMs(t.Created) +} diff --git a/app/cli/internal/trace/opencode/parse_test.go b/app/cli/internal/trace/opencode/parse_test.go new file mode 100644 index 000000000..4598a1e10 --- /dev/null +++ b/app/cli/internal/trace/opencode/parse_test.go @@ -0,0 +1,331 @@ +// +// 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 opencode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sampleExport is a minimal opencode export JSON with one user message, +// two assistant messages (with tokens/cost), and a completed tool part. +const sampleExport = `{ + "info": { + "id": "sess_abc123", + "slug": "refactor-auth", + "title": "Refactor auth module", + "directory": "/home/user/project", + "version": "0.5.0", + "cost": 0.0523, + "tokens": { + "input": 12000, + "output": 3000, + "reasoning": 500, + "cache": { "read": 2000, "write": 1000 } + }, + "time": { "created": 1751702400000, "updated": 1751702700000 } + }, + "messages": [ + { + "info": { + "role": "user", + "id": "msg_001", + "model": { "providerID": "anthropic", "modelID": "claude-sonnet-4-20250514" }, + "time": { "created": 1751702400000 } + }, + "parts": [{ "type": "text", "id": "prt_001", "text": "Fix the auth bug" }] + }, + { + "info": { + "role": "assistant", + "id": "msg_002", + "modelID": "claude-sonnet-4-20250514", + "providerID": "anthropic", + "cost": 0.0261, + "tokens": { + "input": 6000, + "output": 1500, + "reasoning": 250, + "cache": { "read": 1000, "write": 500 } + }, + "time": { "created": 1751702500000, "completed": 1751702550000 } + }, + "parts": [ + { "type": "text", "id": "prt_002", "text": "I'll fix the auth bug." }, + { "type": "tool", "id": "prt_003", "tool": "edit", "state": { "status": "completed" } }, + { "type": "tool", "id": "prt_004", "tool": "read", "state": { "status": "completed" } } + ] + }, + { + "info": { + "role": "assistant", + "id": "msg_003", + "modelID": "claude-sonnet-4-20250514", + "providerID": "anthropic", + "cost": 0.0262, + "tokens": { + "input": 6000, + "output": 1500, + "reasoning": 250, + "cache": { "read": 1000, "write": 500 } + }, + "time": { "created": 1751702600000, "completed": 1751702700000 } + }, + "parts": [ + { "type": "tool", "id": "prt_005", "tool": "edit", "state": { "status": "completed" } } + ] + } + ] +}` + +func TestParseExport(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "sess_abc123.jsonl") + require.NoError(t, os.WriteFile(path, []byte(sampleExport), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "sess_abc123", + }) + require.NoError(t, err) + + assert.Equal(t, "CHAINLOOP_AI_CODING_SESSION", result.ID) + assert.Equal(t, "v1", result.Data.SchemaVersion) + assert.Equal(t, "opencode", result.Data.Agent.Name) + assert.Equal(t, "0.5.0", result.Data.Agent.Version) + assert.Equal(t, "sess_abc123", result.Data.Session.ID) + assert.Equal(t, "refactor-auth", result.Data.Session.Slug) + + // Duration: 5 minutes = 300 seconds + assert.Equal(t, 300, result.Data.Session.DurationSeconds) + + // Model + require.NotNil(t, result.Data.Model) + assert.Equal(t, "claude-sonnet-4-20250514", result.Data.Model.Primary) + assert.Equal(t, "anthropic", result.Data.Model.Provider) + assert.Contains(t, result.Data.Model.ModelsUsed, "claude-sonnet-4-20250514") + + // Usage: session-level tokens are authoritative when present. + require.NotNil(t, result.Data.Usage) + assert.Equal(t, 12000, result.Data.Usage.InputTokens) + assert.Equal(t, 3000, result.Data.Usage.OutputTokens) + assert.Equal(t, 15000, result.Data.Usage.TotalTokens) + assert.Equal(t, 2000, result.Data.Usage.CacheReadInputTokens) + assert.Equal(t, 1000, result.Data.Usage.CacheCreationInputTokens) + assert.InDelta(t, 0.0523, result.Data.Usage.EstimatedCostUSD, 0.0001) + + // Tools: 2 edits + 1 read = 3 total + require.NotNil(t, result.Data.ToolsUsed) + assert.Equal(t, 3, result.Data.ToolsUsed.TotalInvocations) + + got := map[string]int{} + for _, s := range result.Data.ToolsUsed.Summary { + got[s.ToolName] = s.InvocationCount + } + assert.Equal(t, 2, got["edit"]) + assert.Equal(t, 1, got["read"]) + + // Highest-count tool must come first (sorted desc by count). + assert.Equal(t, "edit", result.Data.ToolsUsed.Summary[0].ToolName) + + // Conversation + require.NotNil(t, result.Data.Conversation) + assert.Equal(t, 1, result.Data.Conversation.UserMessages) + assert.Equal(t, 2, result.Data.Conversation.AssistantMessages) + assert.Equal(t, 3, result.Data.Conversation.TotalMessages) + + // Raw session: one entry per message (3 messages = 3 entries) + require.Contains(t, result.Data.RawSession, "main") + assert.Len(t, result.Data.RawSession["main"], 3) + + // Verify the first entry is a user message with text content + var firstEntry map[string]any + require.NoError(t, json.Unmarshal(result.Data.RawSession["main"][0], &firstEntry)) + assert.Equal(t, "user", firstEntry["type"]) + assert.Equal(t, "msg_001", firstEntry["uuid"]) + + msg, ok := firstEntry["message"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "user", msg["role"]) + + content, ok := msg["content"].([]any) + require.True(t, ok) + require.Len(t, content, 1) + block := content[0].(map[string]any) + assert.Equal(t, "text", block["type"]) + assert.Equal(t, "Fix the auth bug", block["text"]) + + // Verify the second entry is an assistant message with text + tool_use blocks + var secondEntry map[string]any + require.NoError(t, json.Unmarshal(result.Data.RawSession["main"][1], &secondEntry)) + assert.Equal(t, "assistant", secondEntry["type"]) + assert.Equal(t, "msg_002", secondEntry["uuid"]) + + msg2, ok := secondEntry["message"].(map[string]any) + require.True(t, ok) + content2, ok := msg2["content"].([]any) + require.True(t, ok) + // 1 text block + 2 tool_use blocks + require.Len(t, content2, 3) + assert.Equal(t, "text", content2[0].(map[string]any)["type"]) + assert.Equal(t, "tool_use", content2[1].(map[string]any)["type"]) + assert.Equal(t, "edit", content2[1].(map[string]any)["name"]) + assert.Equal(t, "tool_use", content2[2].(map[string]any)["type"]) + assert.Equal(t, "read", content2[2].(map[string]any)["name"]) +} + +func TestParseExportFallsBackToMessageTokens(t *testing.T) { + // When session-level tokens are absent, the parser sums per-message tokens. + export := `{ + "info": { + "id": "sess_notokens", + "title": "No session tokens", + "directory": "/repo", + "version": "0.5.0", + "time": { "created": 1751702400000, "updated": 1751702700000 } + }, + "messages": [ + { + "info": { "role": "assistant", "id": "m1", "modelID": "gpt-4o", "providerID": "openai", "cost": 0.01, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 5, "write": 3 } } } + }, + { + "info": { "role": "assistant", "id": "m2", "modelID": "gpt-4o", "providerID": "openai", "cost": 0.02, + "tokens": { "input": 200, "output": 100, "reasoning": 20, "cache": { "read": 10, "write": 7 } } } + } + ] +}` + + rawDir := t.TempDir() + path := filepath.Join(rawDir, state.SanitizeID("sess_notokens")+".jsonl") + require.NoError(t, os.WriteFile(path, []byte(export), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "sess_notokens", + }) + require.NoError(t, err) + + require.NotNil(t, result.Data.Usage) + assert.Equal(t, 300, result.Data.Usage.InputTokens) // 100 + 200 + assert.Equal(t, 150, result.Data.Usage.OutputTokens) // 50 + 100 + assert.Equal(t, 450, result.Data.Usage.TotalTokens) + assert.Equal(t, 15, result.Data.Usage.CacheReadInputTokens) // 5 + 10 + assert.Equal(t, 10, result.Data.Usage.CacheCreationInputTokens) // 3 + 7 + assert.InDelta(t, 0.03, result.Data.Usage.EstimatedCostUSD, 0.0001) +} + +func TestParseExportEmptySession(t *testing.T) { + export := `{ + "info": { + "id": "sess_empty", + "title": "Empty", + "directory": "/repo", + "version": "0.5.0", + "time": { "created": 1751702400000, "updated": 1751702400000 } + }, + "messages": [] +}` + + rawDir := t.TempDir() + path := filepath.Join(rawDir, state.SanitizeID("sess_empty")+".jsonl") + require.NoError(t, os.WriteFile(path, []byte(export), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "sess_empty", + }) + require.NoError(t, err) + + assert.Equal(t, "opencode", result.Data.Agent.Name) + assert.Equal(t, "sess_empty", result.Data.Session.ID) + assert.Equal(t, 0, result.Data.Session.DurationSeconds) + require.NotNil(t, result.Data.Usage) + assert.Equal(t, 0, result.Data.Usage.InputTokens) + require.NotNil(t, result.Data.ToolsUsed) + assert.Equal(t, 0, result.Data.ToolsUsed.TotalInvocations) + require.NotNil(t, result.Data.Conversation) + assert.Equal(t, 0, result.Data.Conversation.TotalMessages) +} + +func TestParseExportMissingFile(t *testing.T) { + p := New() + _, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: t.TempDir(), + SessionID: "does-not-exist", + }) + assert.Error(t, err) +} + +func TestParseExportInvalidJSON(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, state.SanitizeID("bad")+".jsonl") + require.NoError(t, os.WriteFile(path, []byte("not json"), 0600)) + + p := New() + _, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "bad", + }) + assert.Error(t, err) +} + +func TestParseExportCraftingCompatibility(t *testing.T) { + rawDir := t.TempDir() + path := filepath.Join(rawDir, "sess_abc123.jsonl") + require.NoError(t, os.WriteFile(path, []byte(sampleExport), 0600)) + + p := New() + result, err := p.ParseSession(context.Background(), &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: "sess_abc123", + }) + require.NoError(t, err) + + resultBytes, err := json.Marshal(result) + require.NoError(t, err) + + var envelope struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(resultBytes, &envelope)) + require.NotEmpty(t, envelope.Data) + + var upstreamData aicodingsession.Data + require.NoError(t, json.Unmarshal(envelope.Data, &upstreamData)) + assert.Equal(t, "opencode", upstreamData.Agent.Name) + assert.Equal(t, "sess_abc123", upstreamData.Session.ID) + require.Contains(t, upstreamData.RawSession, "main") + assert.NotEmpty(t, upstreamData.RawSession["main"]) +} + +func TestFormatEpochMs(t *testing.T) { + assert.Equal(t, "2025-07-05T08:00:00Z", formatEpochMs(1751702400000)) + assert.Equal(t, "", formatEpochMs(0)) + assert.Equal(t, "", formatEpochMs(-1)) +} diff --git a/app/cli/internal/trace/opencode/provider.go b/app/cli/internal/trace/opencode/provider.go new file mode 100644 index 000000000..3b61a1cfd --- /dev/null +++ b/app/cli/internal/trace/opencode/provider.go @@ -0,0 +1,176 @@ +// +// 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 opencode + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "time" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// Name is the provider identifier emitted to evidence and used as the key +// in SessionRecord.Provider. Kept as an exported constant so CLI wiring +// can refer to it without instantiating the provider. +const Name = "opencode" + +// Compile-time check that Provider implements trace.Provider. +var _ trace.Provider = (*Provider)(nil) + +// Provider implements trace.Provider for opencode sessions. +type Provider struct{} + +// New creates a new opencode provider. +func New() *Provider { + return &Provider{} +} + +// Name returns the agent identifier. +func (p *Provider) Name() string { + return Name +} + +// DiscoverSession finds the most recent opencode session for the given repo root. +// Returns nil, nil if no matching session is found or the opencode binary is unavailable. +func (p *Provider) DiscoverSession(repoRoot string) (*trace.DiscoveredSession, error) { + session, err := discoverOpenCodeSession(repoRoot) + if err != nil || session == nil { + return nil, err + } + + return &trace.DiscoveredSession{ + SessionID: session.ID, + SessionDir: "", + // opencode sessions live in a SQLite DB, not a directory; there's + // no reliable "alive" signal from session list alone. Treat + // discovered sessions as potentially active. + IsActive: true, + }, nil +} + +// SessionDirForRepo returns "" for opencode — sessions are stored in a +// SQLite database, not a per-repo directory. The method exists to satisfy +// the interface; callers use CopySessionData for the actual data extraction. +func (p *Provider) SessionDirForRepo(_ string) string { + return "" +} + +// CopySessionData runs `opencode export ` and streams the JSON +// output directly to the store's raw/.jsonl so +// pre-push can parse it independently of opencode's own storage. stdout is +// redirected to the destination file rather than a pipe: opencode +// (Node.js) doesn't reliably flush stdout to pipes, causing truncation at +// pipe buffer boundaries, but a regular file destination is safe. Fails +// gracefully (returns nil) when the opencode binary is missing or the +// export fails — the session is skipped rather than blocking the push, +// and any partially-written file is removed. +func (p *Provider) CopySessionData(store *state.Store, _, sessionID string) error { + if !opencodeBinaryAvailable() { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + rawDir := store.RawSessionDir() + if err := os.MkdirAll(rawDir, 0755); err != nil { + return fmt.Errorf("create raw dir: %w", err) + } + + dst := state.RawSessionPath(rawDir, sessionID) + f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("create raw session file: %w", err) + } + + cmd := exec.CommandContext(ctx, "opencode", "export", sessionID) + cmd.Stdout = f + runErr := cmd.Run() + _ = f.Close() + if runErr != nil { + _ = os.Remove(dst) + return nil + } + + return nil +} + +// CaptureFileSnapshot reads the file at input.FilePath and stores its +// content under the store's snapshots/ directory so the post-tool-use +// handler can compute line-range diffs after the edit. A missing file +// (e.g. write creating a new file) is treated as a non-error. +func (p *Provider) CaptureFileSnapshot(store *state.Store, input *trace.HookInput) error { + if input == nil || input.FilePath == "" { + return nil + } + + content, err := os.ReadFile(input.FilePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return fmt.Errorf("read %q: %w", input.FilePath, err) + } + + return store.SaveFileSnapshot(input.SessionID, input.FilePath, content) +} + +// ResolveBeforeContent returns the snapshot saved by CaptureFileSnapshot. +func (p *Provider) ResolveBeforeContent(store *state.Store, input *trace.HookInput, _ []byte) []byte { + if input == nil || input.FilePath == "" { + return nil + } + + snap, err := store.LoadFileSnapshot(input.SessionID, input.FilePath) + if err != nil { + return nil + } + + return snap +} + +// CleanupAfterEdit removes the per-edit snapshot. +func (p *Provider) CleanupAfterEdit(store *state.Store, input *trace.HookInput) { + if input == nil || input.FilePath == "" { + return + } + + store.DeleteFileSnapshot(input.SessionID, input.FilePath) +} + +// SystemMessage is a no-op for opencode: the plugin system has no +// systemMessage channel comparable to Claude Code's SessionStart output. +func (p *Provider) SystemMessage(_ string) error { + return nil +} + +// ParseSession reads the copied export JSON for sessionID and returns +// structured evidence. +func (p *Provider) ParseSession(_ context.Context, opts *trace.ParseOpts) (*aicodingsession.Evidence, error) { + path, err := state.FindRawSessionFile(opts.SessionDir, opts.SessionID) + if err != nil { + return nil, fmt.Errorf("locate opencode export: %w", err) + } + + return parseExport(path) +} diff --git a/app/cli/internal/trace/opencode/session.go b/app/cli/internal/trace/opencode/session.go new file mode 100644 index 000000000..7ba26b54f --- /dev/null +++ b/app/cli/internal/trace/opencode/session.go @@ -0,0 +1,70 @@ +// +// 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 opencode + +import ( + "context" + "encoding/json" + "os/exec" + "sort" + "time" +) + +// discoverOpenCodeSession runs `opencode session list --format json`, filters +// sessions whose directory matches repoRoot, and returns the most recently +// updated one. Returns nil, nil when the binary is missing, the command fails, +// or no matching session is found — discovery is best-effort and must never +// block pre-push. +func discoverOpenCodeSession(repoRoot string) (*sessionListEntry, error) { + if !opencodeBinaryAvailable() { + return nil, nil + } + + // Bound the call so a hung opencode binary can never block pre-push. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "opencode", "session", "list", "--format", "json") + out, err := cmd.Output() + if err != nil { + return nil, nil + } + + var sessions []sessionListEntry + if err := json.Unmarshal(out, &sessions); err != nil { + return nil, nil + } + + // Keep only sessions whose directory matches repoRoot. opencode stores + // the absolute directory the session was started in; compare verbatim. + var matching []sessionListEntry + for _, s := range sessions { + if s.Directory == repoRoot { + matching = append(matching, s) + } + } + + if len(matching) == 0 { + return nil, nil + } + + // Sort by Updated descending — most recent first. + sort.Slice(matching, func(i, j int) bool { + return matching[i].Updated > matching[j].Updated + }) + + return &matching[0], nil +} diff --git a/app/cli/internal/trace/opencode/session_test.go b/app/cli/internal/trace/opencode/session_test.go new file mode 100644 index 000000000..bfd118b43 --- /dev/null +++ b/app/cli/internal/trace/opencode/session_test.go @@ -0,0 +1,121 @@ +// +// 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 opencode + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeMockOpenCode creates a fake `opencode` binary in a temp dir that +// prints the given JSON to stdout. Returns the temp dir (to use as PATH). +// Uses printf (a POSIX shell builtin) so it works even when PATH is +// restricted to only the temp dir. +func writeMockOpenCode(t *testing.T, output string) string { + t.Helper() + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "opencode") + // printf is a shell builtin — no PATH dependency. + script := "#!/bin/sh\nprintf '%s' " + quoteForSh(output) + "\n" + //nolint:gosec // the mock is executed through PATH, so it must be executable + require.NoError(t, os.WriteFile(binPath, []byte(script), 0755)) + return tmpDir +} + +// quoteForSh wraps a string in single quotes for safe shell use. +// Test data uses double quotes for JSON, so no embedded single-quote +// escaping is needed. +func quoteForSh(s string) string { + return "'" + s + "'" +} + +func TestDiscoverOpenCodeSessionNoBinary(t *testing.T) { + // Force PATH to empty so opencode is not found. + t.Setenv("PATH", "") + session, err := discoverOpenCodeSession("/repo") + assert.NoError(t, err) + assert.Nil(t, session) +} + +func TestDiscoverOpenCodeSessionFromMockBinary(t *testing.T) { + output := `[{"id":"sess_old","title":"Old","directory":"/other/repo","updated":1000,"created":900},{"id":"sess_recent","title":"Recent","directory":"/my/repo","updated":2000,"created":1900},{"id":"sess_older","title":"Older","directory":"/my/repo","updated":1500,"created":1400}]` + t.Setenv("PATH", writeMockOpenCode(t, output)) + + session, err := discoverOpenCodeSession("/my/repo") + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, "sess_recent", session.ID) + assert.Equal(t, "/my/repo", session.Directory) +} + +func TestDiscoverOpenCodeSessionNoMatch(t *testing.T) { + output := `[{"id":"sess1","title":"S1","directory":"/other/repo","updated":1000,"created":900}]` + t.Setenv("PATH", writeMockOpenCode(t, output)) + + session, err := discoverOpenCodeSession("/my/repo") + assert.NoError(t, err) + assert.Nil(t, session) +} + +func TestDiscoverOpenCodeSessionBinaryFails(t *testing.T) { + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "opencode") + // printf to stderr is not a builtin, but exit 1 is enough to fail. + script := "#!/bin/sh\nexit 1\n" + //nolint:gosec // the mock is executed through PATH, so it must be executable + require.NoError(t, os.WriteFile(binPath, []byte(script), 0755)) + t.Setenv("PATH", tmpDir) + + session, err := discoverOpenCodeSession("/my/repo") + assert.NoError(t, err) + assert.Nil(t, session) +} + +func TestDiscoverOpenCodeSessionInvalidJSON(t *testing.T) { + t.Setenv("PATH", writeMockOpenCode(t, "not json")) + + session, err := discoverOpenCodeSession("/my/repo") + assert.NoError(t, err) + assert.Nil(t, session) +} + +func TestDiscoverSessionViaProvider(t *testing.T) { + output := `[{"id":"provider-session","title":"Test","directory":"/tmp/provider-test","updated":2000,"created":1000}]` + t.Setenv("PATH", writeMockOpenCode(t, output)) + + provider := New() + assert.Equal(t, "opencode", provider.Name()) + + discovered, err := provider.DiscoverSession("/tmp/provider-test") + require.NoError(t, err) + require.NotNil(t, discovered) + assert.Equal(t, "provider-session", discovered.SessionID) + assert.True(t, discovered.IsActive) +} + +func TestSessionListEntryParsing(t *testing.T) { + raw := `[{"id":"s1","title":"T1","directory":"/r","updated":1.5,"created":1.0}]` + var entries []sessionListEntry + require.NoError(t, json.Unmarshal([]byte(raw), &entries)) + require.Len(t, entries, 1) + assert.Equal(t, "s1", entries[0].ID) + assert.Equal(t, "/r", entries[0].Directory) +} diff --git a/app/cli/internal/trace/opencode/testdata/plugin_full.ts b/app/cli/internal/trace/opencode/testdata/plugin_full.ts new file mode 100644 index 000000000..57befcbcf --- /dev/null +++ b/app/cli/internal/trace/opencode/testdata/plugin_full.ts @@ -0,0 +1,94 @@ +import type { Plugin } from "@opencode-ai/plugin" + +export const ChainloopTrace: Plugin = async ({ $ }) => { + const fileWritingTools = ["edit","write","apply_patch"] + const commandTools = ["bash"] + + function filePathsFromArgs(args: any): string[] { + if (args?.filePath) return [args.filePath] + if (args?.path) return [args.path] + if (args?.patchText) return parsePatchPaths(args.patchText) + return [] + } + + // parsePatchPaths extracts affected file paths from an apply_patch + // patchText payload. Each section starts with *** Add File:, *** Update + // File:, or *** Delete File: followed by the path. Paths are deduplicated + // while preserving first-seen order. + function parsePatchPaths(patchText: string): string[] { + const paths: string[] = [] + const seen = new Set() + const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let m + while ((m = re.exec(patchText)) !== null) { + const p = m[1].trim() + if (p && !seen.has(p)) { + seen.add(p) + paths.push(p) + } + } + return paths + } + + // fire-and-forget: tracing must never block tool execution. If chainloop + // is unavailable or errors, log to stderr and move on. + async function fire(event: string, payload: Record) { + const json = JSON.stringify(payload) + try { + await $`echo ${json} | chainloop trace hook opencode ${event}` + } catch (err) { + console.error(`chainloop-trace: ${event} hook failed: ${err}`) + } + } + + return { + event: async ({ event }) => { + if (event.type === "session.created") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + } + if (event.type === "session.deleted") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-end", { session_id: sessionID, hook_event_name: "session.deleted" }) + } + }, + "tool.execute.before": async (input, output) => { + if (commandTools.includes(input.tool)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(output.args)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + file_path: fp, + }) + } + }, + "tool.execute.after": async (input) => { + if (commandTools.includes(input.tool)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(input.args)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + file_path: fp, + }) + } + }, + } +} diff --git a/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts new file mode 100644 index 000000000..fba6019db --- /dev/null +++ b/app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts @@ -0,0 +1,90 @@ +import type { Plugin } from "@opencode-ai/plugin" + +export const ChainloopTrace: Plugin = async ({ $ }) => { + const fileWritingTools = ["edit","write","apply_patch"] + const commandTools = ["bash"] + + function filePathsFromArgs(args: any): string[] { + if (args?.filePath) return [args.filePath] + if (args?.path) return [args.path] + if (args?.patchText) return parsePatchPaths(args.patchText) + return [] + } + + // parsePatchPaths extracts affected file paths from an apply_patch + // patchText payload. Each section starts with *** Add File:, *** Update + // File:, or *** Delete File: followed by the path. Paths are deduplicated + // while preserving first-seen order. + function parsePatchPaths(patchText: string): string[] { + const paths: string[] = [] + const seen = new Set() + const re = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm + let m + while ((m = re.exec(patchText)) !== null) { + const p = m[1].trim() + if (p && !seen.has(p)) { + seen.add(p) + paths.push(p) + } + } + return paths + } + + // fire-and-forget: tracing must never block tool execution. If chainloop + // is unavailable or errors, log to stderr and move on. + async function fire(event: string, payload: Record) { + const json = JSON.stringify(payload) + try { + await $`echo ${json} | chainloop trace hook opencode ${event}` + } catch (err) { + console.error(`chainloop-trace: ${event} hook failed: ${err}`) + } + } + + return { + event: async ({ event }) => { + if (event.type === "session.created") { + const sessionID = event.properties?.info?.id ?? "" + await fire("session-start", { session_id: sessionID, hook_event_name: "session.created" }) + } + }, + "tool.execute.before": async (input, output) => { + if (commandTools.includes(input.tool)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(output.args)) { + await fire("pre-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.before", + tool_name: input.tool, + file_path: fp, + }) + } + }, + "tool.execute.after": async (input) => { + if (commandTools.includes(input.tool)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + }) + return + } + if (!fileWritingTools.includes(input.tool)) return + for (const fp of filePathsFromArgs(input.args)) { + await fire("post-tool-use", { + session_id: input.sessionID, + hook_event_name: "tool.execute.after", + tool_name: input.tool, + file_path: fp, + }) + } + }, + } +} diff --git a/app/cli/internal/trace/opencode/types.go b/app/cli/internal/trace/opencode/types.go new file mode 100644 index 000000000..e190a7279 --- /dev/null +++ b/app/cli/internal/trace/opencode/types.go @@ -0,0 +1,126 @@ +// +// 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 opencode + +import "encoding/json" + +// types.go mirrors the JSON structures emitted by `opencode export ` +// and `opencode session list --format json`. Only the fields consumed by the +// trace provider are modelled; unknown fields are ignored by encoding/json. + +// exportData is the top-level shape of `opencode export`: +// +// { "info": SessionInfo, "messages": [{ "info": MessageInfo, "parts": [Part] }] } +type exportData struct { + Info sessionInfo `json:"info"` + Messages []messageEntry `json:"messages"` +} + +// sessionInfo mirrors SessionV1.SessionInfo in the opencode schema. +type sessionInfo struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Directory string `json:"directory"` + Version string `json:"version"` + Cost *float64 `json:"cost,omitempty"` + Tokens *sessTokens `json:"tokens,omitempty"` + Time sessTime `json:"time"` +} + +type sessTime struct { + Created float64 `json:"created"` + Updated float64 `json:"updated"` +} + +type sessTokens struct { + Input int `json:"input"` + Output int `json:"output"` + Cache sessTokenCache `json:"cache"` +} + +type sessTokenCache struct { + Read int `json:"read"` + Write int `json:"write"` +} + +// messageEntry pairs a message's metadata with its parts. +type messageEntry struct { + Info messageInfo `json:"info"` + Parts []part `json:"parts"` +} + +// messageInfo is the union of User and Assistant message metadata. The +// discriminator is the Role field. Only fields we consume are modelled. +type messageInfo struct { + Role string `json:"role"` + ID string `json:"id"` + ModelID string `json:"modelID,omitempty"` + ProviderID string `json:"providerID,omitempty"` + Cost float64 `json:"cost,omitempty"` + Tokens *msgTokens `json:"tokens,omitempty"` + Time *msgTime `json:"time,omitempty"` + Model *userModelInfo `json:"model,omitempty"` +} + +type userModelInfo struct { + ProviderID string `json:"providerID"` + ModelID string `json:"modelID"` +} + +type msgTokens struct { + Input int `json:"input"` + Output int `json:"output"` + Cache msgTokenCache `json:"cache"` +} + +type msgTokenCache struct { + Read int `json:"read"` + Write int `json:"write"` +} + +type msgTime struct { + Created float64 `json:"created"` + Completed float64 `json:"completed,omitempty"` +} + +// part is a union of all part types in the opencode schema. The +// discriminator is the Type field. Only fields we consume are modelled. +type part struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + State *toolState `json:"state,omitempty"` +} + +// toolState is the union of tool states. The discriminator is the Status +// field. Input and Output are captured as raw JSON so the frontend can +// display tool call details without us modelling every tool's schema. +type toolState struct { + Status string `json:"status"` + Input json.RawMessage `json:"input,omitempty"` + Output json.RawMessage `json:"output,omitempty"` +} + +// sessionListEntry mirrors one element of `opencode session list --format json`. +type sessionListEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Directory string `json:"directory"` + Updated float64 `json:"updated"` + Created float64 `json:"created"` +} diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go new file mode 100644 index 000000000..d1e12099f --- /dev/null +++ b/app/cli/internal/trace/provider.go @@ -0,0 +1,162 @@ +// +// 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 trace + +import ( + "context" + "io" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// Provider discovers and parses AI coding sessions for a specific agent. +// +// Providers are stateless singletons from a registry, so the state-touching +// methods below take a *state.Store per call rather than holding one. The store +// already knows where trace state lives — the .git directory inside a +// repository, or the out-of-tree directory `chainloop trace run` picks outside +// one — so implementations never reason about that distinction. +type Provider interface { + // Name returns the agent identifier (e.g., "claude-code", "cursor"). + Name() string + + // DiscoverSession finds the most relevant session for the given repo root. + // Returns nil, nil if no matching session is found. + DiscoverSession(repoRoot string) (*DiscoveredSession, error) + + // ParseSession parses a session and returns structured evidence. + ParseSession(ctx context.Context, opts *ParseOpts) (*aicodingsession.Evidence, error) + + // SessionDirForRepo returns the agent's session data directory for a given repo root. + SessionDirForRepo(repoRoot string) string + + // CopySessionData copies the agent's on-disk session artifacts into + // the store's raw/ directory so pre-push can parse them independently of + // the agent's own storage (which may be rotated/cleaned later). + CopySessionData(store *state.Store, repoRoot, sessionID string) error + + // CaptureFileSnapshot is invoked from the pre-edit hook to record any + // state the provider needs to later reconstruct the file's pre-edit + // content. Providers whose post-edit hook delivers the edit payload + // directly (e.g. Cursor's afterFileEdit with old/new strings) + // implement this as a no-op. + CaptureFileSnapshot(store *state.Store, input *HookInput) error + + // ResolveBeforeContent reconstructs the file's content as it was + // before the edit, given its current ("after") content. Returns nil + // when no reconstruction is possible (no snapshot, no edits, or the + // file was newly created); callers treat nil as "all lines are AI". + ResolveBeforeContent(store *state.Store, input *HookInput, after []byte) []byte + + // CleanupAfterEdit releases any per-edit state captured in + // CaptureFileSnapshot. Called once the post-edit handler is done with + // the file, regardless of whether ranges were recorded. + CleanupAfterEdit(store *state.Store, input *HookInput) + + // InstallHooks installs the agent's hooks in the repo (e.g., .claude/settings.json). + InstallHooks(repoRoot string) error + + // InstallHooksForTraceRun installs the data-gathering subset of hooks + // used by `chainloop trace run`. End-of-session hooks are omitted — + // trace run drives attestation itself. + InstallHooksForTraceRun(repoRoot string) error + + // SettingsFile returns the absolute path to the agent's on-disk + // hooks/settings file (e.g. .claude/settings.json). Callers use it to + // back up the file before install and restore it afterwards. + SettingsFile(repoRoot string) string + + // UninstallHooks removes the agent's hooks from the repo. + UninstallHooks(repoRoot string) error + + // ReadHookInput reads hook invocation input from the given reader. + ReadHookInput(r io.Reader) (*HookInput, error) + + // IsFileWritingTool returns true if the named tool modifies files on disk. + IsFileWritingTool(toolName string) bool + + // IsCommandTool returns true if the named tool runs a shell command (e.g. + // Claude's "Bash"). Such tools can create or modify arbitrary files without + // firing the file-writing hooks, so they are captured via a before/after + // working-tree snapshot instead of a per-file snapshot. + IsCommandTool(toolName string) bool + + // SystemMessage writes a message to stdout for the agent to display on session start. + SystemMessage(msg string) error +} + +// HookInput represents parsed hook invocation data from an AI agent. +type HookInput struct { + // SessionID is the agent-assigned identifier for the session. + SessionID string `json:"session_id"` + // HookEventName is the agent-specific event name (e.g., "PreToolUse", "afterFileEdit"). + HookEventName string `json:"hook_event_name,omitempty"` + // ToolName is the name of the tool being invoked, if applicable. + ToolName string `json:"tool_name,omitempty"` + // FilePath is the absolute path of the file being edited, set by provider's ReadHookInput. + FilePath string `json:"-"` + // AgentVersion is the agent runtime version reported in the hook payload + // (e.g., Cursor's cursor_version). Captured at session-start so parsing + // can set Agent.Version even when the transcript itself doesn't carry it. + AgentVersion string `json:"-"` + // Model is the model identifier reported in the hook payload (e.g., + // Cursor's "model" field). Captured at session-start so parsing can + // set Model.Primary even when the transcript itself doesn't carry it. + Model string `json:"-"` + // Edits carries per-file string replacements reported by the agent. + // Providers that snapshot files pre-edit leave this empty; providers that + // only emit post-edit events (e.g., Cursor's afterFileEdit) populate it so + // consumers can reconstruct the "before" content via reverse application. + Edits []HookEdit `json:"-"` +} + +// HookEdit represents a single old_string → new_string replacement applied to a file. +type HookEdit struct { + // OldString is the text that was replaced. + OldString string + // NewString is the text that replaced OldString. + NewString string +} + +// DiscoveredSession represents a discovered AI coding session (agent-agnostic). +type DiscoveredSession struct { + SessionID string + SessionDir string + IsActive bool +} + +// ParseOpts configures session parsing. +type ParseOpts struct { + // SessionDir is the directory holding the copied session transcript. + SessionDir string + // SessionID identifies the session to parse. + SessionID string + // AgentVersion is the runtime version captured at session-start (when + // the agent reports it via hook payload). Providers whose transcripts + // don't embed a version can use this to populate Agent.Version. + AgentVersion string + // Model is the model identifier captured at session-start. Providers + // whose transcripts don't embed model info can use this to populate + // Model.Primary. + Model string +} + +// DefaultProviderName is the provider used when a SessionRecord predates the +// Provider field or when no explicit provider is selected. Kept here so +// both the providers registry and the action package can reference it +// without a package import. +const DefaultProviderName = "claude-code" diff --git a/app/cli/internal/trace/providers/registry.go b/app/cli/internal/trace/providers/registry.go new file mode 100644 index 000000000..183192db7 --- /dev/null +++ b/app/cli/internal/trace/providers/registry.go @@ -0,0 +1,76 @@ +// +// 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 providers registers all available AI coding agent trace providers +// in one place. Callers (CLI commands, pre-push hook) use this registry +// instead of instantiating provider packages directly, so adding a new +// provider is a one-line change here. +package providers + +import ( + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" +) + +// DefaultProvider is the provider name used when none is specified +// (e.g. `trace init` with no --claude/--cursor flag, or a SessionRecord +// predating the Provider field). +const DefaultProvider = trace.DefaultProviderName + +// all holds the canonical set of providers. Instances are allocated once at +// init so callers don't pay for re-construction on every lookup. +var all = []trace.Provider{ + claude.New(), + cursor.New(), + opencode.New(), +} + +// byName is a lookup-optimized view of all, built once at init. +var byName = func() map[string]trace.Provider { + m := make(map[string]trace.Provider, len(all)) + for _, p := range all { + m[p.Name()] = p + } + + return m +}() + +// All returns every registered provider in a stable, deterministic order. +// The order is meaningful for user-facing listings in `trace init` logs. +func All() []trace.Provider { + return all +} + +// ByName returns the registered provider with the given name, or nil when +// the name is unknown. +func ByName(name string) trace.Provider { + return byName[name] +} + +// ByNames resolves a list of names into providers, dropping any unknown +// entries. Returns an empty slice (not nil) when no names match, so +// callers can range without nil checks. +func ByNames(names []string) []trace.Provider { + out := make([]trace.Provider, 0, len(names)) + for _, n := range names { + if p := byName[n]; p != nil { + out = append(out, p) + } + } + + return out +} diff --git a/app/cli/internal/trace/state/ailines.go b/app/cli/internal/trace/state/ailines.go new file mode 100644 index 000000000..6c192795d --- /dev/null +++ b/app/cli/internal/trace/state/ailines.go @@ -0,0 +1,126 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" +) + +// AILineAttribution tracks which line ranges in which files a session modified. +type AILineAttribution struct { + SessionID string `json:"session_id"` + Files map[string][]aicodingsession.LineRange `json:"files"` +} + +// newAILineAttribution returns an AILineAttribution with an initialized Files map. +func newAILineAttribution(sessionID string) *AILineAttribution { + return &AILineAttribution{SessionID: sessionID, Files: make(map[string][]aicodingsession.LineRange)} +} + +// aiLinesPath returns the path for a session's AI line attribution file (JSONL). +func (s *Store) aiLinesPath(sessionID string) string { + return filepath.Join(s.traceDirPath(), traceDirAILines, sanitizeID(sessionID)+".jsonl") +} + +// aiLineEntry is a single JSONL record appended by RecordLineRanges. +type aiLineEntry struct { + // SessionID is the agent-assigned session ID. The filename carries only + // its sanitized form, which readers cannot map back to the original. + SessionID string `json:"session_id,omitempty"` + File string `json:"file"` + Ranges []aicodingsession.LineRange `json:"ranges"` +} + +// LoadAILineAttribution loads the AI line attribution for a session by +// consolidating all JSONL entries into a single AILineAttribution. +// Returns a zero-value struct if the file doesn't exist. +func (s *Store) LoadAILineAttribution(sessionID string) *AILineAttribution { + attr := newAILineAttribution(sessionID) + + f, err := os.Open(s.aiLinesPath(sessionID)) + if err != nil { + return attr + } + defer func() { _ = f.Close() }() + + dec := json.NewDecoder(f) + for dec.More() { + var entry aiLineEntry + if err := dec.Decode(&entry); err != nil { + break + } + if entry.SessionID != "" { + attr.SessionID = entry.SessionID + } + attr.Files[entry.File] = append(attr.Files[entry.File], entry.Ranges...) + } + + return attr +} + +// RecordLineRanges appends line ranges for a file to a session's attribution JSONL. +// An empty or nil ranges slice still records the file as touched (e.g., deletion-only edits). +func (s *Store) RecordLineRanges(sessionID, filePath string, ranges []aicodingsession.LineRange) error { + path := s.aiLinesPath(sessionID) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create ai-lines dir: %w", err) + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + + if err := json.NewEncoder(f).Encode(aiLineEntry{SessionID: sessionID, File: filePath, Ranges: ranges}); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +// LoadAllAILineAttributions loads AI line attribution for all sessions that have data. +func (s *Store) LoadAllAILineAttributions() ([]*AILineAttribution, error) { + dir := filepath.Join(s.traceDirPath(), traceDirAILines) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var result []*AILineAttribution + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + // The filename is the sanitized ID; LoadAILineAttribution replaces it + // with the agent-assigned one recorded in the file. + attr := s.LoadAILineAttribution(strings.TrimSuffix(entry.Name(), ".jsonl")) + if len(attr.Files) > 0 { + result = append(result, attr) + } + } + + return result, nil +} diff --git a/app/cli/internal/trace/state/commit.go b/app/cli/internal/trace/state/commit.go new file mode 100644 index 000000000..37b6715b2 --- /dev/null +++ b/app/cli/internal/trace/state/commit.go @@ -0,0 +1,96 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +// CommitRecord holds metadata about a commit captured by the post-commit hook. +type CommitRecord struct { + SHA string `json:"sha"` + Message string `json:"message"` + SessionIDs []string `json:"session_ids,omitempty"` + Timestamp string `json:"timestamp"` + // Tracked marks the record as already attested by a previous successful + // trace push. The pre-push hook uses it to skip pushing a redundant + // attestation when no new AI-assisted commits have been recorded since + // the last push (e.g. a bare `git push` with no new commits, or a + // `git tag && git push --tags` over already-pushed commits). + Tracked bool `json:"tracked,omitempty"` +} + +// SaveCommitRecord writes a commit record to /chainloop-trace/commits/.json. +func (s *Store) SaveCommitRecord(rec *CommitRecord) error { + dir := filepath.Join(s.traceDirPath(), traceDirCommits) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(dir, rec.SHA+".json"), data, 0600) +} + +// loadCommitRecord reads a single CommitRecord file. Used by the GC pass +// where we only need to inspect each record's metadata, not load every record +// into memory at once. +func loadCommitRecord(path string) (*CommitRecord, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var rec CommitRecord + if err := json.Unmarshal(data, &rec); err != nil { + return nil, err + } + + return &rec, nil +} + +// LoadAllCommitRecords reads all commit records from /chainloop-trace/commits/. +func (s *Store) LoadAllCommitRecords() ([]*CommitRecord, error) { + dir := filepath.Join(s.traceDirPath(), traceDirCommits) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var records []*CommitRecord + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + rec, err := loadCommitRecord(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + records = append(records, rec) + } + + return records, nil +} diff --git a/app/cli/internal/trace/state/jsonlcopy.go b/app/cli/internal/trace/state/jsonlcopy.go new file mode 100644 index 000000000..55848df48 --- /dev/null +++ b/app/cli/internal/trace/state/jsonlcopy.go @@ -0,0 +1,143 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// CopySessionJSONL copies the Claude Code JSONL file to /chainloop-trace/raw/.jsonl. +// It also copies subagent files if they exist. +func (s *Store) CopySessionJSONL(sessionID, sourceDir string) error { + rawDir := filepath.Join(s.traceDirPath(), traceDirRaw) + if err := os.MkdirAll(rawDir, 0755); err != nil { + return err + } + + // The source lives in the agent's own directory, named by the ID the agent + // assigned; only our copy gets the sanitized name. Joining an unchecked ID + // here would read outside sourceDir. + if !ValidSessionID(sessionID) { + return fmt.Errorf("session ID %q cannot name a transcript file", sessionID) + } + srcPath := filepath.Join(sourceDir, sessionID+".jsonl") + + if err := copyFile(srcPath, RawSessionPath(rawDir, sessionID)); err != nil { + return fmt.Errorf("copy session JSONL: %w", err) + } + + // Copy subagent directory if present — skip when the directory doesn't exist + subagentsSrc := filepath.Join(sourceDir, sessionID, "subagents") + subagentsDst := RawSubagentDir(rawDir, sessionID) + if err := copyDir(subagentsSrc, subagentsDst); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("copy subagent files: %w", err) + } + + return nil +} + +// RawSessionDir returns the path to /chainloop-trace/raw/ for use as session dir in parsing. +func (s *Store) RawSessionDir() string { + return filepath.Join(s.traceDirPath(), traceDirRaw) +} + +// CopyRawSessionFile copies a single session transcript file from srcPath +// to /chainloop-trace/raw/.jsonl. Used by providers +// whose transcripts are single files (e.g. Cursor); Claude's multi-file +// layout (main + subagents) goes through CopySessionJSONL instead. +func (s *Store) CopyRawSessionFile(sessionID, srcPath string) error { + rawDir := s.RawSessionDir() + dst := filepath.Join(rawDir, SanitizeID(sessionID)+".jsonl") + + return copyFile(srcPath, dst) +} + +// RawSessionPath returns the path a session's raw transcript is copied to +// inside rawDir. Readers and writers must both name the file through this +// helper: the sanitized ID it applies is not recoverable from the raw one. +func RawSessionPath(rawDir, sessionID string) string { + return filepath.Join(rawDir, SanitizeID(sessionID)+".jsonl") +} + +// RawSubagentDir returns the directory a session's copied subagent transcripts +// live in inside rawDir. Same naming contract as RawSessionPath. +func RawSubagentDir(rawDir, sessionID string) string { + return filepath.Join(rawDir, SanitizeID(sessionID), "subagents") +} + +// FindRawSessionFile returns the path to the raw session file for sessionID +// within rawDir, or an error wrapping os.ErrNotExist when missing. +func FindRawSessionFile(rawDir, sessionID string) (string, error) { + path := RawSessionPath(rawDir, sessionID) + if _, err := os.Stat(path); err != nil { + return "", err + } + + return path, nil +} + +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = srcFile.Close() }() + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer func() { _ = dstFile.Close() }() + + _, err = io.Copy(dstFile, srcFile) + return err +} + +func copyDir(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, dstPath); err != nil { + return err + } + } + } + + return nil +} diff --git a/app/cli/internal/trace/state/locate.go b/app/cli/internal/trace/state/locate.go new file mode 100644 index 000000000..52dab2e9d --- /dev/null +++ b/app/cli/internal/trace/state/locate.go @@ -0,0 +1,134 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" +) + +// Locate returns a Store over the directory that parents chainloop-trace +// state, plus the working-tree root. Inside a git repository those are the .git +// directory and the repo root; outside one, they are the NonGitDir of the +// nearest ancestor of cwd with an active `chainloop trace run`, and that +// ancestor. The returned Store reports which of the two it is via IsGit. +// +// Constructing the Store here is deliberate: callers never hold a bare state +// path they could confuse with the repo root returned alongside it. +// +// The non-git branch probes the run-active sentinel rather than the mere +// existence of a state directory, so one left behind by a crashed run is not +// bound to by an unrelated later invocation. Only ErrNotARepository reaches +// that branch: a broken .git means a repository is there and unusable, and +// binding such a clone to an ancestor's out-of-tree state would silently +// record its commits nowhere. +func Locate() (store *Store, root string, err error) { + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + switch { + case err == nil: + return NewGitStore(gitDir), repoRoot, nil + case !errors.Is(err, tracegit.ErrNotARepository): + return nil, "", err + } + + cwd, err := os.Getwd() + if err != nil { + return nil, "", fmt.Errorf("get working directory: %w", err) + } + + base, err := nonGitBase() + if err != nil { + return nil, "", err + } + + for dir := resolveDir(cwd); ; { + candidate := NewOutOfTreeStore(filepath.Join(base, hashDir(dir))) + if candidate.IsTraceRunActive() { + return candidate, dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return nil, "", fmt.Errorf("not a git repository and no active chainloop trace run found from %q up to root", cwd) +} + +// NonGitDir returns the out-of-tree directory that parents trace state for a +// working directory with no git repository, under /chainloop/trace/. +// +// The path is derived from dir alone so that hook subprocesses recompute it +// instead of inheriting it: a hook is a grandchild of `chainloop trace run` +// (run -> agent -> hook), and an agent that rewrites the environment would +// otherwise send the hook to a different directory where it would silently +// record nothing. The user cache dir is $HOME-derived, which survives that far +// better than $TMPDIR, and it keeps live session state away from /tmp reapers. +func NonGitDir(dir string) (string, error) { + base, err := nonGitBase() + if err != nil { + return "", err + } + + return filepath.Join(base, hashDir(dir)), nil +} + +// nonGitBase returns the parent of every non-git trace state directory. +// An error here means $HOME (and $XDG_CACHE_HOME) are unset — the one case that +// must not be papered over with a fallback path, since the run and its hooks +// would then disagree on where state lives. +func nonGitBase() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("locate user cache directory: %w", err) + } + + return filepath.Join(cacheDir, "chainloop", "trace"), nil +} + +// hashDir returns the state directory name for a working directory. Symlinks +// are resolved first so two spellings of the same directory (e.g. /tmp/x and +// /private/tmp/x on macOS) map to one state directory. +func hashDir(dir string) string { + sum := sha256.Sum256([]byte(resolveDir(dir))) + + return hex.EncodeToString(sum[:])[:16] +} + +// resolveDir returns dir as an absolute, symlink-free path, degrading to the +// closest approximation it can compute. Duplicated from the config package, +// which cannot import this one. +func resolveDir(dir string) string { + abs, err := filepath.Abs(dir) + if err != nil { + return dir + } + + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return abs + } + + return resolved +} diff --git a/app/cli/internal/trace/state/locate_test.go b/app/cli/internal/trace/state/locate_test.go new file mode 100644 index 000000000..b40c6c8a2 --- /dev/null +++ b/app/cli/internal/trace/state/locate_test.go @@ -0,0 +1,169 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "os" + "path/filepath" + "testing" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// isolateCacheDir points os.UserCacheDir at a temp directory so non-git state +// tests never touch the developer's real cache. HOME covers macOS, where +// os.UserCacheDir derives from it; XDG_CACHE_HOME covers Linux. +func isolateCacheDir(t *testing.T) { + t.Helper() + + cache := t.TempDir() + t.Setenv("HOME", cache) + t.Setenv("XDG_CACHE_HOME", cache) +} + +// TestNonGitDirSymlink guards the failure mode that would silently stop +// all recording: `trace run` and its hook subprocesses reaching the same +// directory by different names must agree on one state directory. +func TestNonGitDirSymlink(t *testing.T) { + isolateCacheDir(t) + + realDir := t.TempDir() + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(realDir, link)) + + viaReal, err := NonGitDir(realDir) + require.NoError(t, err) + viaLink, err := NonGitDir(link) + require.NoError(t, err) + + assert.Equal(t, viaReal, viaLink, "a symlink must resolve to the same state dir as its target") + assert.True(t, filepath.IsAbs(viaReal), "should return an absolute path") +} + +func TestNonGitDirIsPerDirectory(t *testing.T) { + isolateCacheDir(t) + + first, err := NonGitDir(t.TempDir()) + require.NoError(t, err) + second, err := NonGitDir(t.TempDir()) + require.NoError(t, err) + + assert.NotEqual(t, first, second, "two working directories must not share a state dir") +} + +func TestLocate(t *testing.T) { + t.Run("inside a git repository returns .git", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0755)) + t.Chdir(dir) + + store, root, err := Locate() + require.NoError(t, err) + assert.True(t, store.IsGit()) + assert.Equal(t, filepath.Join(root, ".git"), store.GitDir()) + }) + + t.Run("no repository and no active run errors", func(t *testing.T) { + isolateCacheDir(t) + t.Chdir(t.TempDir()) + + _, _, err := Locate() + assert.Error(t, err) + }) + + t.Run("no repository binds to the active run in cwd", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + t.Chdir(dir) + + want, err := NonGitDir(dir) + require.NoError(t, err) + wantStore := NewOutOfTreeStore(want) + require.NoError(t, wantStore.InitTraceDir()) + require.NoError(t, wantStore.MarkTraceRunActive()) + + store, root, err := Locate() + require.NoError(t, err) + assert.False(t, store.IsGit()) + assert.Empty(t, store.GitDir(), "an out-of-tree store must not pass for a .git directory") + assert.Equal(t, want, store.Dir()) + assert.Equal(t, resolveDir(dir), root) + }) + + t.Run("no repository walks up to an ancestor's active run", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + nested := filepath.Join(dir, "a", "b") + require.NoError(t, os.MkdirAll(nested, 0755)) + + want, err := NonGitDir(dir) + require.NoError(t, err) + wantStore := NewOutOfTreeStore(want) + require.NoError(t, wantStore.InitTraceDir()) + require.NoError(t, wantStore.MarkTraceRunActive()) + + t.Chdir(nested) + + store, root, err := Locate() + require.NoError(t, err) + assert.False(t, store.IsGit()) + assert.Empty(t, store.GitDir(), "an out-of-tree store must not pass for a .git directory") + assert.Equal(t, want, store.Dir()) + assert.Equal(t, resolveDir(dir), root) + }) + + // A broken .git must surface, not silently degrade to non-git mode: the + // clone's commits would then be recorded against an unrelated state dir. + t.Run("a broken .git is not treated as no repository", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /nope/missing\n"), 0600)) + t.Chdir(dir) + + // An active run in the same directory is what Locate would wrongly + // bind to if it read every git error as "no repository". + nonGit, err := NonGitDir(dir) + require.NoError(t, err) + require.NoError(t, NewOutOfTreeStore(nonGit).MarkTraceRunActive()) + + _, _, err = Locate() + require.Error(t, err) + assert.NotErrorIs(t, err, tracegit.ErrNotARepository) + }) + + t.Run("a state dir without the sentinel is ignored", func(t *testing.T) { + isolateCacheDir(t) + + dir := t.TempDir() + t.Chdir(dir) + + // Leftovers from a crashed run that already cleared the sentinel + // must not capture an unrelated invocation. + stale, err := NonGitDir(dir) + require.NoError(t, err) + require.NoError(t, NewOutOfTreeStore(stale).InitTraceDir()) + + _, _, err = Locate() + assert.Error(t, err) + }) +} diff --git a/app/cli/internal/trace/state/session.go b/app/cli/internal/trace/state/session.go new file mode 100644 index 000000000..8d898fd94 --- /dev/null +++ b/app/cli/internal/trace/state/session.go @@ -0,0 +1,119 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// SessionRecord holds metadata about an AI coding agent session. +type SessionRecord struct { + // SessionID is the agent-assigned identifier for this session. + SessionID string `json:"session_id"` + // Provider identifies the agent that produced this session (e.g. "claude-code", "cursor"). + // Empty on records written before multi-provider support; consumers should treat that as claude-code. + Provider string `json:"provider,omitempty"` + // AgentVersion is the agent runtime version reported at session-start + // (e.g., Cursor's cursor_version). Empty for providers whose hook payload + // does not carry the version. Recorded once on the first hook only. + AgentVersion string `json:"agent_version,omitempty"` + // Model is the model identifier reported at session-start (e.g., + // Cursor's "model" field). Empty for providers whose hook payload does + // not carry the model. Recorded once on the first hook only. + Model string `json:"model,omitempty"` + // Active reports whether the session is ongoing at the time of record write. + Active bool `json:"active"` + // StartedAt is the RFC3339 timestamp of when tracking began for this session. + StartedAt string `json:"started_at"` +} + +// SaveSessionRecord writes a session record to /chainloop-trace/sessions/.json. +func (s *Store) SaveSessionRecord(rec *SessionRecord) error { + dir := filepath.Join(s.traceDirPath(), traceDirSessions) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(dir, sanitizeID(rec.SessionID)+".json"), data, 0600) +} + +// SessionRecordExists checks whether a session record file exists for the given session ID. +func (s *Store) SessionRecordExists(sessionID string) bool { + path := filepath.Join(s.traceDirPath(), traceDirSessions, sanitizeID(sessionID)+".json") + _, err := os.Stat(path) + return err == nil +} + +// LoadSessionRecord reads the SessionRecord for sessionID. Returns nil, nil when not found. +func (s *Store) LoadSessionRecord(sessionID string) (*SessionRecord, error) { + path := filepath.Join(s.traceDirPath(), traceDirSessions, sanitizeID(sessionID)+".json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, err + } + + var rec SessionRecord + if err := json.Unmarshal(data, &rec); err != nil { + return nil, err + } + + return &rec, nil +} + +// LoadAllSessionRecords reads every SessionRecord under +// /chainloop-trace/sessions/ into a map keyed by SessionID. A missing +// directory yields an empty (non-nil) map and no error; per-file decode +// errors are skipped so a single malformed record doesn't abort the load. +func (s *Store) LoadAllSessionRecords() (map[string]*SessionRecord, error) { + dir := filepath.Join(s.traceDirPath(), traceDirSessions) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return map[string]*SessionRecord{}, nil + } + + return nil, err + } + + out := make(map[string]*SessionRecord, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + var rec SessionRecord + if err := json.Unmarshal(data, &rec); err != nil { + continue + } + out[rec.SessionID] = &rec + } + + return out, nil +} diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go new file mode 100644 index 000000000..594381997 --- /dev/null +++ b/app/cli/internal/trace/state/snapshot.go @@ -0,0 +1,97 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// snapshotPath returns the path for a file snapshot: /chainloop-trace/snapshots// +func (s *Store) snapshotPath(sessionID, filePath string) string { + h := sha256.Sum256([]byte(filePath)) + name := hex.EncodeToString(h[:8]) // 16-char hex, enough to avoid collisions + return filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(sessionID), name) +} + +// SaveFileSnapshot stores a file's content before an AI edit. +func (s *Store) SaveFileSnapshot(sessionID, filePath string, content []byte) error { + path := s.snapshotPath(sessionID, filePath) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create snapshot dir: %w", err) + } + + return os.WriteFile(path, content, 0600) +} + +// LoadFileSnapshot loads a previously stored file snapshot. +func (s *Store) LoadFileSnapshot(sessionID, filePath string) ([]byte, error) { + return os.ReadFile(s.snapshotPath(sessionID, filePath)) +} + +// DeleteFileSnapshot removes a file snapshot after it's been processed. +func (s *Store) DeleteFileSnapshot(sessionID, filePath string) { + path := s.snapshotPath(sessionID, filePath) + _ = os.Remove(path) +} + +// shellPreSignaturePath returns the path storing the pre-command working-tree +// signature for a session: /chainloop-trace/snapshots//shell-pre.json +func (s *Store) shellPreSignaturePath(sessionID string) string { + return filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(sessionID), "shell-pre.json") +} + +// SaveShellPreSignature stores the working-tree signature captured before an +// agent-run shell command, so the post-command hook can diff against it. A +// single slot per session is used; concurrent shell calls in one turn overwrite +// it (see the parallel-shell limitation). +func (s *Store) SaveShellPreSignature(sessionID string, sig map[string]string) error { + path := s.shellPreSignaturePath(sessionID) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create snapshot dir: %w", err) + } + + data, err := json.Marshal(sig) + if err != nil { + return fmt.Errorf("marshal shell signature: %w", err) + } + + return os.WriteFile(path, data, 0600) +} + +// LoadShellPreSignature loads the pre-command working-tree signature for a session. +func (s *Store) LoadShellPreSignature(sessionID string) (map[string]string, error) { + data, err := os.ReadFile(s.shellPreSignaturePath(sessionID)) + if err != nil { + return nil, err + } + + var sig map[string]string + if err := json.Unmarshal(data, &sig); err != nil { + return nil, fmt.Errorf("parse shell signature: %w", err) + } + + return sig, nil +} + +// DeleteShellPreSignature removes the pre-command signature once processed. +func (s *Store) DeleteShellPreSignature(sessionID string) { + _ = os.Remove(s.shellPreSignaturePath(sessionID)) +} diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go new file mode 100644 index 000000000..5f828683e --- /dev/null +++ b/app/cli/internal/trace/state/snapshot_test.go @@ -0,0 +1,43 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShellPreSignatureRoundTrip(t *testing.T) { + store := NewGitStore(t.TempDir()) + sessionID := "sess-123" + sig := map[string]string{ + "a.go": "hash-a", + "sub/b.json": "hash-b", + } + + require.NoError(t, store.SaveShellPreSignature(sessionID, sig)) + + loaded, err := store.LoadShellPreSignature(sessionID) + require.NoError(t, err) + assert.Equal(t, sig, loaded) + + store.DeleteShellPreSignature(sessionID) + + _, err = store.LoadShellPreSignature(sessionID) + assert.Error(t, err, "signature should be gone after delete") +} diff --git a/app/cli/internal/trace/state/state.go b/app/cli/internal/trace/state/state.go new file mode 100644 index 000000000..084c56751 --- /dev/null +++ b/app/cli/internal/trace/state/state.go @@ -0,0 +1,282 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + // traceDir is the directory holding all trace state. It is created + // inside the Store's parent directory: .git/ inside a repository, or + // the out-of-tree directory `chainloop trace run` picks outside one. + traceDir = "chainloop-trace" + + // Subdirectories inside traceDir. + traceInitializedFile = "initialized" + // traceRunActiveFile is the sentinel created by `chainloop trace run` + // for the lifetime of its wrapped command. It lets hook subprocesses + // detect trace-run mode without depending on environment-variable + // inheritance from the agent. + traceRunActiveFile = "run-active" + traceDirSessions = "sessions" + traceDirRaw = "raw" + traceDirCommits = "commits" + traceDirSnapshots = "snapshots" + traceDirAILines = "ai-lines" + + // Per-record file extensions inside the subdirectories above. + commitRecordExt = ".json" + sessionRecordExt = ".json" + aiLinesExt = ".jsonl" +) + +// InitTraceDir creates the /chainloop-trace/ directory structure. +func (s *Store) InitTraceDir() error { + base := s.traceDirPath() + for _, sub := range []string{traceDirSessions, traceDirRaw, traceDirCommits, traceDirSnapshots, traceDirAILines} { + if err := os.MkdirAll(filepath.Join(base, sub), 0755); err != nil { + return fmt.Errorf("create %s directory: %w", sub, err) + } + } + return nil +} + +// WipeTraceDir clears the single-use data left over by a successful push: +// raw transcripts and per-edit file snapshots. It intentionally preserves +// commits/, ai-lines/, and sessions/ — those carry session attribution that +// survives across pushes (a rebase replays commits with the same trailers, so +// the next push can still attribute them). Bounded growth of the surviving +// subdirectories is the job of GCOrphans, called after the wipe. +func (s *Store) WipeTraceDir() error { + base := s.traceDirPath() + + for _, sub := range []string{traceDirRaw, traceDirSnapshots} { + if err := os.RemoveAll(filepath.Join(base, sub)); err != nil { + return fmt.Errorf("remove %s: %w", sub, err) + } + } + + // Re-create empty subdirectories + return s.InitTraceDir() +} + +// GCOrphans drops trace state whose underlying commit is no longer reachable +// from any local branch. liveSHAs is the union of SHAs reachable from every +// local branch tip; the caller computes it from the git client. +// +// Two cleanups in sequence: +// +// 1. Walk commits/, drop any record whose SHA isn't in liveSHAs (post-rebase +// orphan or commit on a deleted branch). +// 2. Walk ai-lines/ and sessions/, drop any entry whose session ID is no +// longer referenced by a surviving CommitRecord. +// +// An empty liveSHAs is treated as a pathological signal (no local branches +// readable, or the caller fed us garbage) and the GC short-circuits — wiping +// every record on a transient git error would be far worse than skipping a +// cycle of cleanup. +func (s *Store) GCOrphans(liveSHAs map[string]bool) error { + if len(liveSHAs) == 0 { + return nil + } + + commitsDir := filepath.Join(s.traceDirPath(), traceDirCommits) + commitEntries, err := os.ReadDir(commitsDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read commits dir: %w", err) + } + + liveSessions := make(map[string]struct{}) + for _, entry := range commitEntries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), commitRecordExt) { + continue + } + path := filepath.Join(commitsDir, entry.Name()) + rec, readErr := loadCommitRecord(path) + if readErr != nil { + // Unreadable record — drop it; we can't reason about its SHA. + _ = os.Remove(path) + continue + } + if !liveSHAs[rec.SHA] { + _ = os.Remove(path) + continue + } + for _, sid := range rec.SessionIDs { + liveSessions[sanitizeID(sid)] = struct{}{} + } + } + + for _, child := range []struct { + sub string + ext string + }{ + {traceDirAILines, aiLinesExt}, + {traceDirSessions, sessionRecordExt}, + } { + dir := filepath.Join(s.traceDirPath(), child.sub) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + + return fmt.Errorf("read %s dir: %w", child.sub, err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), child.ext) { + continue + } + id := strings.TrimSuffix(entry.Name(), child.ext) + if _, alive := liveSessions[id]; !alive { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } + } + + return nil +} + +// RemoveTraceDir removes the entire /chainloop-trace/ directory. +func (s *Store) RemoveTraceDir() error { + return os.RemoveAll(s.traceDirPath()) +} + +// LogFilePath returns the path to the hook log file inside the trace directory. +func (s *Store) LogFilePath() string { + return filepath.Join(s.traceDirPath(), "log.txt") +} + +// AttestationStatePath returns the path to the local attestation state file +// under /chainloop-trace/. This file isolates trace attestation state +// from the default chainloop state location. +func (s *Store) AttestationStatePath() string { + return filepath.Join(s.traceDirPath(), "attestation-state.json") +} + +// IsTraceInitialized checks whether trace has been initialized in this clone. +func (s *Store) IsTraceInitialized() bool { + _, err := os.Stat(filepath.Join(s.traceDirPath(), traceInitializedFile)) + return err == nil +} + +// MarkTraceInitialized creates the sentinel file indicating trace is initialized. +func (s *Store) MarkTraceInitialized() error { + base := s.traceDirPath() + if err := os.MkdirAll(base, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(base, traceInitializedFile), nil, 0600) +} + +// RemoveTraceInitialized removes the sentinel file. +func (s *Store) RemoveTraceInitialized() error { + return removeIfExists(filepath.Join(s.traceDirPath(), traceInitializedFile)) +} + +// MarkTraceRunActive marks the trace state as belonging to a running +// `chainloop trace run` session. Removed by ClearTraceRunActive when +// the wrapped command exits. +func (s *Store) MarkTraceRunActive() error { + base := s.traceDirPath() + if err := os.MkdirAll(base, 0755); err != nil { + return err + } + + return os.WriteFile(filepath.Join(base, traceRunActiveFile), nil, 0600) +} + +// ClearTraceRunActive removes the trace-run-active sentinel. +func (s *Store) ClearTraceRunActive() error { + return removeIfExists(filepath.Join(s.traceDirPath(), traceRunActiveFile)) +} + +// IsTraceRunActive reports whether a `chainloop trace run` session owns +// this store's trace state. +func (s *Store) IsTraceRunActive() bool { + _, err := os.Stat(filepath.Join(s.traceDirPath(), traceRunActiveFile)) + + return err == nil +} + +// ValidSessionID reports whether an agent-assigned session ID can safely name +// a file. IDs reach us from agent hook payloads and from commit-message +// trailers, and both are used to read the agent's own transcript directory, so +// anything that is not a single path element could escape it. Real IDs are +// UUID- or slug-shaped, so this rejects nothing legitimate. +// +// Writers additionally pass IDs through SanitizeID, which keeps a rejected ID +// from escaping our own state directory even if a caller skips this check. +func ValidSessionID(id string) bool { + return id != "" && id != "." && id != ".." && filepath.Base(id) == id +} + +// SanitizeID strips path separators and traversal components from an +// identifier before using it in filesystem paths. Exported so providers +// in other packages compute the same on-disk path that the writers in +// this package emit; otherwise an ID containing "/" or ".." would write +// to one filename and be read from another. +// +// A rewritten ID also gets a digest suffix, since distinct IDs ("a/x" and +// "a_x") would otherwise collapse onto one filename and merge two sessions' +// state. Separating the rewritten set is all that is needed: agent-assigned +// IDs are UUID-shaped and pass through untouched. +// +// Returning an already-safe ID verbatim makes this idempotent, which callers +// depend on — GCOrphans compares filename-derived keys against sanitized ones, +// and LoadAllAILineAttributions feeds a filename straight back in. Always +// appending the digest would double-suffix those keys and match nothing. +func SanitizeID(id string) string { + safe := filepath.Base(strings.ReplaceAll(id, "/", "_")) + if safe == "." || safe == ".." { + safe = "_" + } + if safe == id { + return safe + } + + sum := sha256.Sum256([]byte(id)) + + return fmt.Sprintf("%s-%x", safe, sum[:4]) +} + +// sanitizeID is kept as a private alias so existing call sites in this +// package read naturally; new code outside the package should use SanitizeID. +func sanitizeID(id string) string { + return SanitizeID(id) +} + +// removeIfExists removes a file, returning nil if it doesn't exist. +func removeIfExists(path string) error { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return err +} + +// NowTimestamp returns the current UTC time in RFC3339 format. +func NowTimestamp() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/app/cli/internal/trace/state/state_test.go b/app/cli/internal/trace/state/state_test.go new file mode 100644 index 000000000..93b4c848b --- /dev/null +++ b/app/cli/internal/trace/state/state_test.go @@ -0,0 +1,361 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogFilePath(t *testing.T) { + assert.Equal(t, filepath.Join("/fake/.git", "chainloop-trace", "log.txt"), NewGitStore("/fake/.git").LogFilePath()) +} + +func TestTraceInitialized(t *testing.T) { + store := NewGitStore(t.TempDir()) + + t.Run("not initialized by default", func(t *testing.T) { + assert.False(t, store.IsTraceInitialized()) + }) + + t.Run("mark and check initialized", func(t *testing.T) { + require.NoError(t, store.MarkTraceInitialized()) + assert.True(t, store.IsTraceInitialized()) + }) + + t.Run("remove initialized", func(t *testing.T) { + require.NoError(t, store.MarkTraceInitialized()) + require.NoError(t, store.RemoveTraceInitialized()) + assert.False(t, store.IsTraceInitialized()) + }) + + t.Run("remove nonexistent is noop", func(t *testing.T) { + empty := NewGitStore(t.TempDir()) + require.NoError(t, os.MkdirAll(empty.traceDirPath(), 0755)) + assert.NoError(t, empty.RemoveTraceInitialized()) + }) +} + +func TestInitTraceDir(t *testing.T) { + store := NewGitStore(t.TempDir()) + + require.NoError(t, store.InitTraceDir()) + + base := store.traceDirPath() + for _, sub := range []string{"sessions", "raw", "commits", "snapshots", "ai-lines"} { + info, err := os.Stat(filepath.Join(base, sub)) + require.NoError(t, err) + assert.True(t, info.IsDir()) + } +} + +func TestWipeTraceDir(t *testing.T) { + store := NewGitStore(t.TempDir()) + + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.MarkTraceInitialized()) + + // Populate one of every kind of state the wipe interacts with. + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "abc123", Message: "test", SessionIDs: []string{"sess-1"}, Timestamp: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.RecordLineRanges("sess-1", "src/foo.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.SaveFileSnapshot("sess-1", "src/foo.go", []byte("before"))) + + require.NoError(t, store.WipeTraceDir()) + + // Sentinel preserved at trace dir root. + assert.True(t, store.IsTraceInitialized()) + + // Single-use state is gone (would otherwise leak into the next push). + _, err := store.LoadFileSnapshot("sess-1", "src/foo.go") + assert.True(t, os.IsNotExist(err), "snapshots should be wiped") + + // Attribution-bearing state survives so a future rebase that replays + // these commits can be re-attributed via the trailer (PFM-5878). + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Len(t, commits, 1, "commits/ must survive the wipe — GC handles bounded growth") + + assert.True(t, store.SessionRecordExists("sess-1"), "session record must survive the wipe") + + attr := store.LoadAILineAttribution("sess-1") + require.NotNil(t, attr) + assert.Equal(t, []aicodingsession.LineRange{{Start: 1, End: 5}}, attr.Files["src/foo.go"], + "ai-lines must survive the wipe so post-rebase attribution still works") +} + +func TestGCOrphans(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + // Three commits; only sha-live is reachable from a local branch. + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "sha-live", SessionIDs: []string{"sess-live"}, Timestamp: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "sha-orphan", SessionIDs: []string{"sess-orphan"}, Timestamp: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "sha-shared", SessionIDs: []string{"sess-live"}, Timestamp: "2026-03-28T00:00:00Z"})) + + // Both sessions have ai-lines + session records. + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-live", Active: true, StartedAt: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-orphan", Active: false, StartedAt: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.RecordLineRanges("sess-live", "live.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.RecordLineRanges("sess-orphan", "orphan.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + + require.NoError(t, store.GCOrphans(map[string]bool{"sha-live": true, "sha-shared": true})) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 2, "orphan SHA must be dropped; live and shared survive") + + gotSHAs := make([]string, 0, len(commits)) + for _, c := range commits { + gotSHAs = append(gotSHAs, c.SHA) + } + assert.ElementsMatch(t, []string{"sha-live", "sha-shared"}, gotSHAs) + + // sess-live still has a live commit; sess-orphan does not. + assert.True(t, store.SessionRecordExists("sess-live")) + assert.False(t, store.SessionRecordExists("sess-orphan"), "session with no live commits must be GCed") + + live := store.LoadAILineAttribution("sess-live") + assert.NotEmpty(t, live.Files, "ai-lines for live session must survive") + orphan := store.LoadAILineAttribution("sess-orphan") + assert.Empty(t, orphan.Files, "ai-lines for orphan session must be dropped") +} + +// An empty liveSHAs (no local branches readable, transient git error) must +// be treated as "skip the GC", not "wipe everything". The latter would erase +// every CommitRecord and ai-lines/sessions entry on a single bad git call, +// taking a rebase-tolerant session with it. +func TestGCOrphans_EmptyLiveSHAsSkipsCleanup(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.SaveCommitRecord(&CommitRecord{SHA: "sha-1", SessionIDs: []string{"sess-1"}, Timestamp: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.SaveSessionRecord(&SessionRecord{SessionID: "sess-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"})) + require.NoError(t, store.RecordLineRanges("sess-1", "foo.go", []aicodingsession.LineRange{{Start: 1, End: 3}})) + + require.NoError(t, store.GCOrphans(map[string]bool{})) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Len(t, commits, 1, "empty liveSHAs must not drop commit records") + assert.True(t, store.SessionRecordExists("sess-1")) + assert.NotEmpty(t, store.LoadAILineAttribution("sess-1").Files) +} + +func TestValidSessionID(t *testing.T) { + for _, id := range []string{"sess-1", "ses_abc123", "9beb4bd3-6eee-42a5-ab49-8fc04eee921c"} { + assert.True(t, ValidSessionID(id), id) + } + + // An ID that is not a single path element would escape the agent's own + // transcript directory when joined into a read path. + for _, id := range []string{"", ".", "..", "../etc/passwd", "a/x", "../../secret"} { + assert.False(t, ValidSessionID(id), id) + } +} + +func TestSanitizeID(t *testing.T) { + // An agent-assigned ID needs no rewriting, so its on-disk name must not move. + assert.Equal(t, "9beb4bd3-6eee-42a5-ab49-8fc04eee921c", SanitizeID("9beb4bd3-6eee-42a5-ab49-8fc04eee921c")) + + // IDs that collide under rewriting must not share a name. + assert.NotEqual(t, SanitizeID("a/x"), SanitizeID("a_x")) + assert.NotEqual(t, SanitizeID("."), SanitizeID("..")) + for _, id := range []string{"a/x", ".", "..", "../etc/passwd"} { + safe := SanitizeID(id) + assert.Equal(t, safe, filepath.Base(safe), "sanitized %q must be a single path element", id) + // GCOrphans and LoadAllAILineAttributions feed sanitized names back in. + assert.Equal(t, safe, SanitizeID(safe), "SanitizeID must be idempotent") + } +} + +func TestSessionRecordExists(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + t.Run("returns false when no record exists", func(t *testing.T) { + assert.False(t, store.SessionRecordExists("nonexistent")) + }) + + t.Run("returns true after saving record", func(t *testing.T) { + rec := &SessionRecord{SessionID: "sess-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + assert.True(t, store.SessionRecordExists("sess-1")) + }) +} + +func TestSessionRecords(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + t.Run("save session record", func(t *testing.T) { + rec := &SessionRecord{SessionID: "sess-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + + // Verify file was written + data, err := os.ReadFile(filepath.Join(store.traceDirPath(), "sessions", "sess-1.json")) + require.NoError(t, err) + assert.Contains(t, string(data), "sess-1") + }) +} + +func TestCommitRecords(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + t.Run("save and load commit records", func(t *testing.T) { + rec1 := &CommitRecord{SHA: "abc123", Message: "first", SessionIDs: []string{"sess-1"}, Timestamp: "2026-03-28T00:00:00Z"} + rec2 := &CommitRecord{SHA: "def456", Message: "second", Timestamp: "2026-03-28T00:01:00Z"} + require.NoError(t, store.SaveCommitRecord(rec1)) + require.NoError(t, store.SaveCommitRecord(rec2)) + + records, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Len(t, records, 2) + + // Find records by SHA + shas := make(map[string]*CommitRecord) + for _, r := range records { + shas[r.SHA] = r + } + assert.Equal(t, []string{"sess-1"}, shas["abc123"].SessionIDs) + assert.Empty(t, shas["def456"].SessionIDs) + }) + + t.Run("load from empty dir returns nil", func(t *testing.T) { + records, err := NewGitStore(t.TempDir()).LoadAllCommitRecords() + require.NoError(t, err) + assert.Nil(t, records) + }) +} + +func TestFileSnapshots(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + t.Run("save and load snapshot", func(t *testing.T) { + content := []byte("func main() {\n\tfmt.Println(\"hello\")\n}\n") + require.NoError(t, store.SaveFileSnapshot("sess-1", "/path/to/file.go", content)) + + loaded, err := store.LoadFileSnapshot("sess-1", "/path/to/file.go") + require.NoError(t, err) + assert.Equal(t, content, loaded) + }) + + t.Run("load nonexistent snapshot returns error", func(t *testing.T) { + _, err := store.LoadFileSnapshot("sess-1", "/nonexistent/file.go") + assert.Error(t, err) + }) + + t.Run("delete snapshot", func(t *testing.T) { + content := []byte("content") + require.NoError(t, store.SaveFileSnapshot("sess-1", "/delete/me.go", content)) + + store.DeleteFileSnapshot("sess-1", "/delete/me.go") + + _, err := store.LoadFileSnapshot("sess-1", "/delete/me.go") + assert.Error(t, err) + }) +} + +func TestAILineAttribution(t *testing.T) { + t.Run("record and load line ranges", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + ranges := []aicodingsession.LineRange{{Start: 1, End: 5}, {Start: 10, End: 15}} + require.NoError(t, store.RecordLineRanges("sess-1", "/path/to/file.go", ranges)) + + attr := store.LoadAILineAttribution("sess-1") + assert.Equal(t, "sess-1", attr.SessionID) + assert.Equal(t, ranges, attr.Files["/path/to/file.go"]) + }) + + t.Run("accumulates ranges for same file", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", "/file.go", []aicodingsession.LineRange{{Start: 1, End: 5}})) + require.NoError(t, store.RecordLineRanges("sess-1", "/file.go", []aicodingsession.LineRange{{Start: 20, End: 25}})) + + attr := store.LoadAILineAttribution("sess-1") + assert.Len(t, attr.Files["/file.go"], 2) + }) + + t.Run("multiple files in one session", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", "/a.go", []aicodingsession.LineRange{{Start: 1, End: 3}})) + require.NoError(t, store.RecordLineRanges("sess-1", "/b.go", []aicodingsession.LineRange{{Start: 5, End: 10}})) + + attr := store.LoadAILineAttribution("sess-1") + assert.Len(t, attr.Files, 2) + }) + + t.Run("empty ranges record file touch", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", "/file.go", nil)) + + attr := store.LoadAILineAttribution("sess-1") + require.Contains(t, attr.Files, "/file.go") + assert.Empty(t, attr.Files["/file.go"]) + + attrs, err := store.LoadAllAILineAttributions() + require.NoError(t, err) + assert.Len(t, attrs, 1) + }) + + t.Run("load nonexistent returns empty", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + attr := store.LoadAILineAttribution("nonexistent") + assert.Empty(t, attr.Files) + }) + + t.Run("load all attributions", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, store.RecordLineRanges("sess-1", "/a.go", []aicodingsession.LineRange{{Start: 1, End: 3}})) + require.NoError(t, store.RecordLineRanges("sess-2", "/b.go", []aicodingsession.LineRange{{Start: 5, End: 10}})) + + attrs, err := store.LoadAllAILineAttributions() + require.NoError(t, err) + assert.Len(t, attrs, 2) + }) + + t.Run("load all reports the unsanitized session ID", func(t *testing.T) { + store := NewGitStore(t.TempDir()) + require.NoError(t, store.InitTraceDir()) + + // Sanitizing "org/sess-1" for the filename loses the original ID, which + // is why the record itself carries it. + rawID := "org/sess-1" + require.NoError(t, store.RecordLineRanges(rawID, "/a.go", []aicodingsession.LineRange{{Start: 1, End: 3}})) + + attrs, err := store.LoadAllAILineAttributions() + require.NoError(t, err) + require.Len(t, attrs, 1) + assert.Equal(t, rawID, attrs[0].SessionID, "consumers key off the agent-assigned ID, not the filename") + }) +} diff --git a/app/cli/internal/trace/state/store.go b/app/cli/internal/trace/state/store.go new file mode 100644 index 000000000..efd378d6d --- /dev/null +++ b/app/cli/internal/trace/state/store.go @@ -0,0 +1,71 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import "path/filepath" + +// Store reads and writes the chainloop-trace state under a single parent +// directory: the .git directory inside a repository, or the out-of-tree +// directory `chainloop trace run` picks outside one. Which of the two it is +// decides whether git hooks can be installed and whether the directory itself +// is ours to remove, so the Store carries that rather than leaving callers to +// thread a second copy of the same path alongside it. +type Store struct { + dir string + isGit bool +} + +// NewGitStore returns a Store over a repository's .git directory. +// Nothing is created or validated here. +func NewGitStore(gitDir string) *Store { + return &Store{dir: gitDir, isGit: true} +} + +// NewOutOfTreeStore returns a Store over the directory that holds trace state +// outside a repository, as returned by NonGitDir. +func NewOutOfTreeStore(dir string) *Store { + return &Store{dir: dir} +} + +// IsGit reports whether the state lives inside a repository's .git directory. +func (s *Store) IsGit() bool { + return s.isGit +} + +// GitDir returns the repository's .git directory, and "" outside a repository. +// Callers that install or remove git hooks must take the path from here, so a +// store that is not a .git directory cannot be mistaken for one. +func (s *Store) GitDir() string { + if !s.isGit { + return "" + } + + return s.dir +} + +// Dir returns the parent directory, one level above chainloop-trace/. +// +// Only `trace run`'s cleanup needs it, to drop the empty out-of-tree directory +// after a non-git run. Inside a repository the same path is .git and must never +// be removed, so callers guarding on IsGit are the only legitimate users. +func (s *Store) Dir() string { + return s.dir +} + +// traceDirPath returns the path to /chainloop-trace/. +func (s *Store) traceDirPath() string { + return filepath.Join(s.dir, traceDir) +} diff --git a/app/cli/internal/trace/state/trailer.go b/app/cli/internal/trace/state/trailer.go new file mode 100644 index 000000000..41cdbbb06 --- /dev/null +++ b/app/cli/internal/trace/state/trailer.go @@ -0,0 +1,67 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "sort" + "strings" +) + +// TrailerKey is the git-style trailer the commit-msg hook writes when AI +// sessions are attributed to staged files. The key is matched case-sensitively +// per git trailer convention. +const TrailerKey = "Chainloop-Trace-Sessions" + +// ParseSessionIDsFromTrailer scans a commit message for `Chainloop-Trace-Sessions:` +// trailer lines and returns the sorted, deduped list of session IDs declared in +// the message. Lines must start with the trailer key (no leading whitespace) to +// match git's trailer parsing rules. Empty values and blank entries between +// commas are ignored. +func ParseSessionIDsFromTrailer(message string) []string { + if message == "" { + return nil + } + + seen := make(map[string]struct{}) + for line := range strings.SplitSeq(message, "\n") { + values, ok := strings.CutPrefix(line, TrailerKey+":") + if !ok { + continue + } + + for v := range strings.SplitSeq(values, ",") { + v = strings.TrimSpace(v) + // Trailers come from commit messages, so an ID that cannot name a + // file is either corrupt or hostile; either way it is not ours. + if !ValidSessionID(v) { + continue + } + seen[v] = struct{}{} + } + } + + if len(seen) == 0 { + return nil + } + + out := make([]string, 0, len(seen)) + for v := range seen { + out = append(out, v) + } + sort.Strings(out) + + return out +} diff --git a/app/cli/internal/trace/state/trailer_test.go b/app/cli/internal/trace/state/trailer_test.go new file mode 100644 index 000000000..52dcc9ad4 --- /dev/null +++ b/app/cli/internal/trace/state/trailer_test.go @@ -0,0 +1,98 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseSessionIDsFromTrailer(t *testing.T) { + cases := []struct { + name string + message string + want []string + }{ + { + name: "empty message", + message: "", + want: nil, + }, + { + name: "no trailer", + message: "fix: typo\n\nA fix.\n", + want: nil, + }, + { + name: "single id", + message: "feat: x\n\nChainloop-Trace-Sessions: abc\n", + want: []string{"abc"}, + }, + { + name: "comma separated values are split, trimmed, and sorted", + message: "feat: x\n\nChainloop-Trace-Sessions: foo, bar\n", + want: []string{"bar", "foo"}, + }, + { + name: "leading and trailing whitespace stripped", + message: "feat: x\n\nChainloop-Trace-Sessions: foo , bar \n", + want: []string{"bar", "foo"}, + }, + { + name: "empty value yields no IDs", + message: "feat: x\n\nChainloop-Trace-Sessions:\n", + want: nil, + }, + { + name: "extra commas are ignored", + message: "feat: x\n\nChainloop-Trace-Sessions: foo,, ,bar\n", + want: []string{"bar", "foo"}, + }, + { + name: "duplicate ids are deduped", + message: "feat: x\n\nChainloop-Trace-Sessions: a, a, b\n", + want: []string{"a", "b"}, + }, + { + name: "unrelated trailer ignored", + message: "feat: x\n\nSigned-off-by: dev \n", + want: nil, + }, + { + name: "case-sensitive key — lowercase is ignored", + message: "feat: x\n\nchainloop-trace-sessions: foo\n", + want: nil, + }, + { + name: "trailer line alongside other trailers", + message: "feat: x\n\nBody.\n\nSigned-off-by: dev \nChainloop-Trace-Sessions: id-1, id-2\n", + want: []string{"id-1", "id-2"}, + }, + { + name: "leading whitespace before the trailer key is ignored", + message: "feat: x\n\n Chainloop-Trace-Sessions: foo\n", + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ParseSessionIDsFromTrailer(tc.message) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/app/cli/internal/trace/types.go b/app/cli/internal/trace/types.go new file mode 100644 index 000000000..5ee7cd18a --- /dev/null +++ b/app/cli/internal/trace/types.go @@ -0,0 +1,72 @@ +// +// 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. + +//nolint:revive // "trace" names the CLI feature; the runtime/trace clash is inert here +package trace + +import "encoding/json" + +// Attribution constants for file-level code attribution. +const ( + // AttributionAI marks file changes produced by an AI agent. + AttributionAI = "ai" + // AttributionHuman marks file changes produced by a human. + AttributionHuman = "human" +) + +// RawSessionEntry is the wire format for a single message in +// raw_session["main"]. All providers must emit entries matching this +// struct so the frontend's ConversationTimeline can render them +// uniformly. The shape mirrors Claude's JSONL transcript format, which +// the frontend was originally built against. +type RawSessionEntry struct { + Type string `json:"type,omitempty"` + Role string `json:"role,omitempty"` + UUID string `json:"uuid,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + IsMeta bool `json:"isMeta,omitempty"` + Message RawSessionMessage `json:"message"` +} + +// RawSessionMessage is the message body inside a RawSessionEntry. +type RawSessionMessage struct { + Role string `json:"role,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + Model string `json:"model,omitempty"` +} + +// RawSessionTextBlock is a content block carrying plain text. +type RawSessionTextBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// RawSessionToolUseBlock is a content block representing a tool call. +type RawSessionToolUseBlock struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +// MustRawSessionEntry marshals a RawSessionEntry to json.RawMessage. +// Panics only on impossible encoding failures (struct field bugs). +func MustRawSessionEntry(e RawSessionEntry) json.RawMessage { + b, err := json.Marshal(e) + if err != nil { + panic(err) + } + return b +} diff --git a/app/cli/main.go b/app/cli/main.go index ba566a15f..2cff30ea1 100644 --- a/app/cli/main.go +++ b/app/cli/main.go @@ -20,6 +20,7 @@ import ( "os" "github.com/chainloop-dev/chainloop/app/cli/cmd" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/go-kratos/kratos/v2/errors" jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" @@ -45,8 +46,12 @@ func main() { }) rootCmd := cmd.NewRootCmd(logger) if err := cmd.Execute(rootCmd); err != nil { - msg, exitCode := errorInfo(err, logger) - logger.Error().Msg(msg) + // cmd.Logger() rather than the local logger: a hook command may have + // swapped the root logger for a colorless one, and this final line + // should match. + hookLogger := cmd.Logger() + msg, exitCode := errorInfo(err, hookLogger) + hookLogger.Error().Msg(msg) os.Exit(exitCode) } } @@ -89,9 +94,15 @@ func errorInfo(err error, logger zerolog.Logger) (string, int) { } var gateErr *cmd.GateError + var subprocessErr *action.SubprocessExitError // Make overrides switch { + case errors.As(err, &subprocessErr): + // `chainloop trace run` — surface the wrapped command's exit code so + // the parent shell sees the same status it would have seen running the + // command directly. + return subprocessErr.Error(), subprocessErr.ExitCode case v1.IsCasBackendErrorReasonRequired(err): msg = "you need to enable a CAS backend first. Refer to `chainloop cas-backend` command or contact your administrator." case v1.IsCasBackendErrorReasonInvalid(err): diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go new file mode 100644 index 000000000..5ef185551 --- /dev/null +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -0,0 +1,333 @@ +// +// 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 ( + "errors" + "os" + "path/filepath" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" +) + +// HandleAgentSessionEnd handles the agent session-end hook. +func HandleAgentSessionEnd(provider trace.Provider, log zerolog.Logger) error { + input, err := provider.ReadHookInput(os.Stdin) + if err != nil || !state.ValidSessionID(input.SessionID) { + log.Debug().Err(err).Msg("session-end: no valid input") + return nil + } + + sessionID := input.SessionID + log.Debug().Str("session_id", sessionID).Msg("session-end hook invoked") + + store, repoRoot, err := state.Locate() + if err != nil { + log.Debug().Err(err).Msg("session-end: no trace state located") + return nil + } + + if err := provider.CopySessionData(store, repoRoot, sessionID); err != nil { + log.Debug().Err(err).Msg("copy session data failed") + } + + log.Debug().Str("session_id", sessionID).Msg("session ended") + + return nil +} + +// HandleAgentSessionStart handles the agent session-start hook. +func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error { + input, err := provider.ReadHookInput(os.Stdin) + if err != nil || !state.ValidSessionID(input.SessionID) { + log.Debug().Err(err).Msg("session-start: no valid input") + return nil + } + + log.Debug().Str("session_id", input.SessionID).Msg("session-start hook invoked") + + store, repoRoot, err := state.Locate() + if err != nil { + log.Debug().Err(err).Msg("session-start: no trace state located") + return nil + } + + ensureSessionTracked(provider, store, repoRoot, input, log) + + if err := provider.SystemMessage("\n\n*** This session will be attested by Chainloop ***"); err != nil { + log.Debug().Err(err).Msg("session-start: failed to send system message") + } + + return nil +} + +// HandleAgentPreToolUse handles the agent pre-tool-use hook. +func HandleAgentPreToolUse(provider trace.Provider, log zerolog.Logger) error { + input, err := provider.ReadHookInput(os.Stdin) + if err != nil || !state.ValidSessionID(input.SessionID) { + log.Debug().Err(err).Msg("pre-tool-use: no valid input") + return nil + } + + log.Debug().Str("session_id", input.SessionID).Msg("pre-tool-use hook invoked") + + store, repoRoot, err := state.Locate() + if err != nil { + log.Debug().Err(err).Msg("pre-tool-use: no trace state located") + return nil + } + + ensureSessionTracked(provider, store, repoRoot, input, log) + + switch { + case provider.IsCommandTool(input.ToolName): + // Shell command: snapshot the whole worktree so the post hook can diff + // it and attribute the command's file changes to the AI. + captureWorktreeSnapshot(store, repoRoot, input.SessionID, log) + case provider.IsFileWritingTool(input.ToolName): + if input.FilePath == "" { + log.Debug().Str("tool", input.ToolName).Msg("pre-tool-use: file-writing tool produced no file path, skipping") + return nil + } + if err := provider.CaptureFileSnapshot(store, input); err != nil { + log.Debug().Err(err).Str("file", input.FilePath).Msg("pre-tool-use: capture snapshot failed") + } + default: + log.Debug().Str("tool", input.ToolName).Msg("pre-tool-use: not a tracked tool, skipping") + } + + return nil +} + +// captureWorktreeSnapshot records the working-tree signature before a shell +// command runs, so HandleAgentPostToolUse can diff it and attribute the files +// the command changed. Best-effort: failures are logged and never block the agent. +func captureWorktreeSnapshot(store *state.Store, repoRoot, sessionID string, log zerolog.Logger) { + sig, err := tracegit.NewGoGitClient().SnapshotWorktree(repoRoot) + if err != nil { + log.Debug().Err(err).Msg("pre-command: worktree snapshot failed") + return + } + + if err := store.SaveShellPreSignature(sessionID, sig); err != nil { + log.Debug().Err(err).Msg("pre-command: save worktree signature failed") + } +} + +// ensureSessionTracked auto-installs git hooks (unconditionally, on every +// invocation — itself idempotent via hooks.IsInstalled), then creates a +// session record if one doesn't already exist and copies the session data. +// Idempotent so any hook entry point can call it safely. Per-input metadata +// (e.g., AgentVersion) is captured on the first call only — subsequent +// hooks for the same session are no-ops, no upsert. +func ensureSessionTracked(provider trace.Provider, store *state.Store, repoRoot string, input *trace.HookInput, log zerolog.Logger) { + // Run install before any early return so hooks recover from missing + // state (deleted .git/hooks, project YAML appearing after session start). + // Outside a repository there is nothing to invoke git hooks, so there is + // nothing to install. + if store.IsGit() { + autoInstallGitHooks(store, repoRoot, log) + } + + sessionID := input.SessionID + if store.SessionRecordExists(sessionID) { + return + } + + log.Debug().Str("session_id", sessionID).Str("state_dir", store.Dir()).Str("provider", provider.Name()).Msg("tracking new session") + + rec := &state.SessionRecord{ + SessionID: sessionID, + Provider: provider.Name(), + AgentVersion: input.AgentVersion, + Model: input.Model, + Active: true, + StartedAt: state.NowTimestamp(), + } + if err := store.SaveSessionRecord(rec); err != nil { + log.Debug().Err(err).Msg("save session record failed") + return + } + + if err := provider.CopySessionData(store, repoRoot, sessionID); err != nil { + log.Debug().Err(err).Msg("copy session data failed") + } +} + +// HandleAgentPostToolUse handles post-edit hooks across providers +// (Claude's post-tool-use, Cursor's afterFileEdit) and records AI-attributed +// line ranges for the edited file. +func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error { + input, err := provider.ReadHookInput(os.Stdin) + if err != nil || !state.ValidSessionID(input.SessionID) { + log.Debug().Err(err).Msg("post-tool-use: no valid input") + return nil + } + + sessionID := input.SessionID + + isCommand := provider.IsCommandTool(input.ToolName) + isFileWriting := provider.IsFileWritingTool(input.ToolName) + if !isCommand && !isFileWriting { + log.Debug().Str("tool", input.ToolName).Msg("post-tool-use: not a tracked tool, skipping") + return nil + } + if isFileWriting && input.FilePath == "" { + log.Debug().Str("tool", input.ToolName).Msg("post-tool-use: file-writing tool produced no file path, skipping") + return nil + } + + log.Debug().Str("session_id", sessionID).Str("tool", input.ToolName).Str("file", input.FilePath).Msg("post-tool-use hook invoked") + + store, repoRoot, err := state.Locate() + if err != nil { + log.Debug().Err(err).Msg("post-tool-use: no trace state located") + return nil + } + + // Guarantees session tracking + git-hook install when afterFileEdit is + // the first hook we see (Cursor has no pre-tool-use). + ensureSessionTracked(provider, store, repoRoot, input, log) + + if isCommand { + // Shell command: diff the before/after worktree snapshots and attribute + // every file the command changed to the AI. + recordCommandLineRanges(store, repoRoot, sessionID, log) + + return nil + } + + after, err := os.ReadFile(input.FilePath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Debug().Err(err).Str("file", input.FilePath).Msg("post-tool-use: cannot read file after edit") + provider.CleanupAfterEdit(store, input) + return nil + } + // File was deleted — treat after as empty so attribution records + // the deletion as AI-authored (ComputeLineRanges returns changed=true + // with nil ranges for before≠nil, after=empty). + after = nil + } + + before := provider.ResolveBeforeContent(store, input, after) + + ranges, changed := attribution.ComputeLineRanges(before, after) + + // Normalize to repo-relative path for storage — git diffs use relative paths, + // so ai-lines keys must match for commit correlation and attribution to work. + relPath, err := filepath.Rel(repoRoot, input.FilePath) + if err != nil { + relPath = input.FilePath + } + + if changed { + if err := store.RecordLineRanges(sessionID, relPath, ranges); err != nil { + log.Debug().Err(err).Msg("post-tool-use: record line ranges failed") + } else { + log.Debug().Int("ranges", len(ranges)).Str("file", relPath).Msg("line ranges recorded") + } + } + + provider.CleanupAfterEdit(store, input) + + return nil +} + +// recordCommandLineRanges diffs the worktree signature captured before a shell +// command against the current worktree, and records every created/modified file +// (whole-file range) and every deleted file as AI-attributed. Enrich later caps +// the AI line count to each file's committed diff totals, so whole-file ranges +// yield correct counts. Best-effort: never blocks the agent. +func recordCommandLineRanges(store *state.Store, repoRoot, sessionID string, log zerolog.Logger) { + before, err := store.LoadShellPreSignature(sessionID) + if err != nil { + // No pre-command snapshot (missed pre hook, parallel overwrite) — skip. + log.Debug().Err(err).Msg("post-command: no pre-command worktree signature") + return + } + defer store.DeleteShellPreSignature(sessionID) + + after, err := tracegit.NewGoGitClient().SnapshotWorktree(repoRoot) + if err != nil { + log.Debug().Err(err).Msg("post-command: worktree snapshot failed") + return + } + + changed, deleted := attribution.ChangedPaths(before, after) + for _, relPath := range changed { + content, err := os.ReadFile(filepath.Join(repoRoot, relPath)) + if err != nil { + continue + } + // ChangedPaths already proved the file changed, so record it even when + // the result is empty (created empty or truncated) — ComputeLineRanges + // returns nil ranges for empty content, which marks the file AI-touched + // with zero added lines, matching the deletion-only treatment. + ranges, _ := attribution.ComputeLineRanges(nil, content) + if err := store.RecordLineRanges(sessionID, relPath, ranges); err != nil { + log.Debug().Err(err).Str("file", relPath).Msg("post-command: record line ranges failed") + } + } + for _, relPath := range deleted { + // nil ranges records the file as AI-touched (deletion-only), matching + // the file-writing hook's treatment of deletions. + if err := store.RecordLineRanges(sessionID, relPath, nil); err != nil { + log.Debug().Err(err).Str("file", relPath).Msg("post-command: record deletion failed") + } + } + + log.Debug().Int("changed", len(changed)).Int("deleted", len(deleted)).Str("session_id", sessionID).Msg("command line ranges recorded") +} + +// autoInstallGitHooks installs git hooks if they're not already present +// and a project name is discoverable. Under `chainloop trace run`, +// pre-push is omitted because trace run drives attestation itself; a +// pre-push installed here would double-attest on in-session `git push`. +// +// Trace-run mode is detected via the run-active sentinel file under +// .git/chainloop-trace/, which is robust to whatever environment each +// agent happens to propagate to its hook subprocesses. +// Only meaningful for a store inside a repository; GitDir is empty otherwise. +func autoInstallGitHooks(store *state.Store, repoRoot string, log zerolog.Logger) { + gitDir := store.GitDir() + skipPrePush := store.IsTraceRunActive() + + if hooks.IsInstalled(gitDir, skipPrePush) { + return + } + + if config.LoadProjectFromYML(repoRoot) == "" { + return // can't auto-install without a project name + } + + if _, err := hooks.Install(gitDir, skipPrePush); err != nil { + // Warn, not debug: some failures (e.g. a leftover hook backup) keep + // tracing off until the user intervenes, so they must be visible. + log.Warn().Err(err).Msg("auto-install git hooks failed") + return + } + + if err := store.MarkTraceInitialized(); err != nil { + log.Debug().Err(err).Msg("mark trace initialized failed") + } +} diff --git a/app/cli/pkg/action/trace_agent_hook_test.go b/app/cli/pkg/action/trace_agent_hook_test.go new file mode 100644 index 000000000..ff02c72b4 --- /dev/null +++ b/app/cli/pkg/action/trace_agent_hook_test.go @@ -0,0 +1,546 @@ +// +// 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 ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/hooks" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// initTempGitRepo creates a temp dir and runs git init in it. +func initTempGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + cmd := exec.Command("git", "init", dir) + require.NoError(t, cmd.Run()) + return dir +} + +func TestHandleAgentSessionStart(t *testing.T) { + provider := claude.New() + + t.Run("captures session ID from stdin", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + withStdin(t, `{"session_id":"abc-123","cwd":"/some/path"}`) + + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + + assert.True(t, store.SessionRecordExists("abc-123")) + }) + + t.Run("no-op when session already tracked", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // Pre-populate session record + rec := &state.SessionRecord{SessionID: "abc-123", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + + withStdin(t, `{"session_id":"abc-123"}`) + + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + + assert.True(t, store.SessionRecordExists("abc-123")) + }) + + t.Run("tracks multiple sessions independently", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // Track first session + rec := &state.SessionRecord{SessionID: "session-1", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + + // Start a new session + withStdin(t, `{"session_id":"session-2"}`) + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + + // Both sessions should have records + assert.True(t, store.SessionRecordExists("session-1")) + assert.True(t, store.SessionRecordExists("session-2")) + }) + + t.Run("outputs system message to stdout", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + withStdin(t, `{"session_id":"msg-test","cwd":"/some/path"}`) + stdout := captureStdout(t, func() { + require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + }) + + assert.Contains(t, stdout, `"systemMessage"`) + assert.Contains(t, stdout, "This session will be attested by Chainloop") + }) + + t.Run("ignores malformed stdin", func(t *testing.T) { + withStdin(t, `not json`) + assert.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + }) + + t.Run("ignores empty session ID", func(t *testing.T) { + withStdin(t, `{"session_id":""}`) + assert.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop())) + }) +} + +func TestHandleAgentSessionEnd(t *testing.T) { + provider := claude.New() + + t.Run("completes without error", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // Pre-populate session record + rec := &state.SessionRecord{SessionID: "abc-123", Active: true, StartedAt: "2026-03-28T00:00:00Z"} + require.NoError(t, store.SaveSessionRecord(rec)) + + withStdin(t, `{"session_id":"abc-123"}`) + require.NoError(t, HandleAgentSessionEnd(provider, zerolog.Nop())) + }) +} + +func TestAutoInstallGitHooks(t *testing.T) { + t.Run("installs hooks when project discoverable from yml", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + // Create .chainloop.yml with project name + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: my-project\n"), 0600)) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + // Git hooks should be installed + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", "post-commit")) + require.NoError(t, err) + assert.Contains(t, string(content), hooks.HookMarker) + + // Trace should be marked as initialized + assert.True(t, store.IsTraceInitialized()) + }) + + t.Run("skips when hooks already installed", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + // Install hooks first + _, err := hooks.Install(gitDir, false) + require.NoError(t, err) + + // Write a marker to verify hooks aren't reinstalled + hookPath := filepath.Join(gitDir, "hooks", "post-commit") + original, _ := os.ReadFile(hookPath) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + // Hook content should be unchanged + current, _ := os.ReadFile(hookPath) + assert.Equal(t, string(original), string(current)) + }) + + t.Run("skips when no project name discoverable", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + // No hooks should be installed + _, err := os.Stat(filepath.Join(gitDir, "hooks", "post-commit")) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("skips pre-push when trace-run-active sentinel is present", func(t *testing.T) { + repoDir := initTempGitRepo(t) + gitDir := filepath.Join(repoDir, ".git") + store := state.NewGitStore(gitDir) + + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".chainloop.yml"), []byte("projectName: my-project\n"), 0600)) + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.MarkTraceRunActive()) + + origDir, _ := os.Getwd() + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + autoInstallGitHooks(store, repoDir, zerolog.Nop()) + + content, err := os.ReadFile(filepath.Join(gitDir, "hooks", "post-commit")) + require.NoError(t, err) + assert.Contains(t, string(content), hooks.HookMarker) + + _, err = os.Stat(filepath.Join(gitDir, "hooks", "pre-push")) + assert.True(t, os.IsNotExist(err), "pre-push must not be installed under trace run mode") + }) +} + +// captureStdout captures stdout output during fn execution. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + origStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = origStdout }() + + fn() + + _ = w.Close() + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +// withStdin replaces os.Stdin with a reader containing the given content for the test duration. +func withStdin(t *testing.T, content string) { + t.Helper() + + origStdin := os.Stdin + r, w, _ := os.Pipe() + _, _ = io.Copy(w, bytes.NewBufferString(content)) + _ = w.Close() + os.Stdin = r + t.Cleanup(func() { os.Stdin = origStdin }) +} + +func TestHandleAgentPostToolUse_DeletionAttribution(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + target := filepath.Join(dir, "victim.txt") + require.NoError(t, os.WriteFile(target, []byte("line 1\nline 2\nline 3\n"), 0600)) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-delete-test","hook_event_name":"tool.execute.before","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + snap, err := store.LoadFileSnapshot("ses-delete-test", target) + require.NoError(t, err) + assert.Equal(t, "line 1\nline 2\nline 3\n", string(snap)) + + require.NoError(t, os.Remove(target)) + + withStdin(t, `{"session_id":"ses-delete-test","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-delete-test") + assert.Contains(t, attr.Files, "victim.txt", + "deleted file must have ai-lines attribution recorded") + assert.Empty(t, attr.Files["victim.txt"], + "deleted file must have nil ranges (no lines in after)") +} + +func TestHandleAgentPostToolUse_UpdateAttribution(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + target := filepath.Join(dir, "updated.txt") + require.NoError(t, os.WriteFile(target, []byte("old line\n"), 0600)) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-update-test","hook_event_name":"tool.execute.before","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(target, []byte("new line\n"), 0600)) + + withStdin(t, `{"session_id":"ses-update-test","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-update-test") + assert.Contains(t, attr.Files, "updated.txt") + ranges := attr.Files["updated.txt"] + require.NotEmpty(t, ranges) + assert.Equal(t, 1, ranges[0].Start) + assert.Equal(t, 1, ranges[0].End) +} + +func TestHandleAgentPostToolUse_AddFileAttribution(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + target := filepath.Join(dir, "newfile.txt") + p := opencode.New() + + withStdin(t, `{"session_id":"ses-add-test","hook_event_name":"tool.execute.before","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(target, []byte("brand\nnew\ncontent\n"), 0600)) + + withStdin(t, `{"session_id":"ses-add-test","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":"`+target+`"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-add-test") + assert.Contains(t, attr.Files, "newfile.txt") + ranges := attr.Files["newfile.txt"] + require.NotEmpty(t, ranges) + assert.Equal(t, 1, ranges[0].Start) + assert.Equal(t, 3, ranges[0].End) +} + +func TestHandleAgentCommandTool_AttributesShellFileChanges(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() // opencode's "bash" is a command tool + + // pre-command: snapshot the worktree before the shell command runs. + withStdin(t, `{"session_id":"ses-cmd","hook_event_name":"tool.execute.before","tool_name":"bash"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + // Simulate the shell command creating a file, modifying a tracked one, and + // creating an empty file (e.g. `touch marker`). + require.NoError(t, os.WriteFile(filepath.Join(dir, "generated.txt"), []byte("a\nb\nc\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init changed\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "marker"), nil, 0600)) + + // post-command: diff the worktree and attribute the changes to the AI. + withStdin(t, `{"session_id":"ses-cmd","hook_event_name":"tool.execute.after","tool_name":"bash"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-cmd") + + // Created file: whole-file range recorded (repo-relative key, no symlink drift). + require.Contains(t, attr.Files, "generated.txt") + require.NotEmpty(t, attr.Files["generated.txt"]) + assert.Equal(t, 1, attr.Files["generated.txt"][0].Start) + assert.Equal(t, 3, attr.Files["generated.txt"][0].End) + + // Modified tracked file is also captured. + assert.Contains(t, attr.Files, "init.txt") + + // Empty file created by the command is recorded (AI-touched, no ranges). + assert.Contains(t, attr.Files, "marker") + assert.Empty(t, attr.Files["marker"]) + + // The pre-command signature is cleaned up afterwards. + _, err := store.LoadShellPreSignature("ses-cmd") + assert.Error(t, err) +} + +// TestHandleAgentClaudeCodeSession drives the pre/post-tool-use handlers with +// the real JSON payloads Claude Code pipes to its hooks, across a full session: +// SessionStart → Write (new file) → Edit (modify it) → Bash (generate a file). +// It asserts the AI-line attribution map ends up crediting every file the agent +// touched — including the one produced only by the shell command, which is the +// PFM-6684 regression. +func TestHandleAgentClaudeCodeSession(t *testing.T) { + root := chdirToResolvedGitRepo(t) + gitDir := filepath.Join(root, ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := claude.New() + const sid = "b1f4e0c2-1a2b-4c3d-8e9f-0a1b2c3d4e5f" + tp := filepath.Join(root, ".claude", "transcript.jsonl") + + // 1. SessionStart — Claude sends session_id, transcript_path, cwd, source. + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"transcript_path":%q,"cwd":%q,"hook_event_name":"SessionStart","source":"startup"}`, + sid, tp, root)) + require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop())) + require.True(t, store.SessionRecordExists(sid), "SessionStart should record the session") + + // 2. Write a brand-new file. Claude fires PreToolUse (file does not exist + // yet), performs the write, then PostToolUse with a tool_response. + apiGo := filepath.Join(root, "api.go") + writeInput := fmt.Sprintf(`"tool_name":"Write","tool_input":{"file_path":%q,"content":"package api\n\nfunc A() {}\n"}`, apiGo) + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PreToolUse",%s}`, sid, writeInput)) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(apiGo, []byte("package api\n\nfunc A() {}\n"), 0600)) // the Write tool's effect + + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PostToolUse",%s,"tool_response":{"type":"create","filePath":%q}}`, sid, writeInput, apiGo)) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + // 3. Edit the file the agent just wrote (append a function). + editInput := fmt.Sprintf(`"tool_name":"Edit","tool_input":{"file_path":%q,"old_string":"func A() {}","new_string":"func A() {}\n\nfunc B() {}"}`, apiGo) + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PreToolUse",%s}`, sid, editInput)) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.WriteFile(apiGo, []byte("package api\n\nfunc A() {}\n\nfunc B() {}\n"), 0600)) // the Edit tool's effect + + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PostToolUse",%s,"tool_response":{"filePath":%q}}`, sid, editInput, apiGo)) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + // 4. Run a Bash command that generates a file. Bash payloads carry a + // "command" in tool_input and never a file_path — this is the path that + // previously misattributed the generated file to "human". + bashInput := `"tool_name":"Bash","tool_input":{"command":"protoc --go_out=. api.proto","description":"generate bindings"}` + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PreToolUse",%s}`, sid, bashInput)) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) + + require.NoError(t, os.MkdirAll(filepath.Join(root, "gen"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "gen", "api.pb.go"), []byte("// generated\npackage gen\n\ntype Msg struct{}\n"), 0600)) // the command's effect + + withStdin(t, fmt.Sprintf(`{"session_id":%q,"hook_event_name":"PostToolUse",%s,"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, sid, bashInput)) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + // Attribution: the Write/Edit file and the Bash-generated file are all AI. + attr := store.LoadAILineAttribution(sid) + + require.Contains(t, attr.Files, "api.go", "Write/Edit file must be recorded") + require.NotEmpty(t, attr.Files["api.go"]) + + require.Contains(t, attr.Files, "gen/api.pb.go", "shell-generated file must be attributed to AI (PFM-6684)") + genRanges := attr.Files["gen/api.pb.go"] + require.NotEmpty(t, genRanges) + assert.Equal(t, 1, genRanges[0].Start) + assert.Equal(t, 4, genRanges[0].End) // whole 4-line generated file + + // The pre-command signature is cleaned up after the Bash post hook. + _, err := store.LoadShellPreSignature(sid) + assert.Error(t, err) +} + +// chdirToResolvedGitRepo creates a git repo, chdirs into its symlink-resolved +// path, and returns that canonical root. Using the resolved path mirrors the +// canonical absolute paths Claude Code passes in hook payloads and keeps +// filepath.Rel keys stable on macOS (where /var is a symlink to /private/var). +func chdirToResolvedGitRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found") + } + + dir := initTempGitRepo(t) + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + + cwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(resolved)) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + return resolved +} + +func TestHandleAgentPostToolUse_EmptyFilePathSkips(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-empty-path","hook_event_name":"tool.execute.after","tool_name":"apply_patch","file_path":""}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-empty-path") + assert.Empty(t, attr.Files) +} + +func TestHandleAgentPostToolUse_NonFileWritingToolSkips(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-non-writing","hook_event_name":"tool.execute.after","tool_name":"bash","file_path":"/some/path"}`) + require.NoError(t, HandleAgentPostToolUse(p, zerolog.Nop())) + + attr := store.LoadAILineAttribution("ses-non-writing") + assert.Empty(t, attr.Files) +} + +func TestHandleAgentPreToolUse_EmptyFilePathSkips(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-pre-empty","hook_event_name":"tool.execute.before","tool_name":"apply_patch","file_path":""}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) +} + +func TestHandleAgentPreToolUse_NonFileWritingToolSkips(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + p := opencode.New() + + withStdin(t, `{"session_id":"ses-pre-non-writing","hook_event_name":"tool.execute.before","tool_name":"bash","file_path":"/some/path"}`) + require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop())) +} diff --git a/app/cli/pkg/action/trace_attestation.go b/app/cli/pkg/action/trace_attestation.go new file mode 100644 index 000000000..1a87dd102 --- /dev/null +++ b/app/cli/pkg/action/trace_attestation.go @@ -0,0 +1,327 @@ +// +// 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" + "fmt" + "io" + "os" + "strings" + + "github.com/chainloop-dev/chainloop/pkg/grpcconn" + "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog" + "github.com/spf13/viper" + "google.golang.org/grpc" +) + +// AttestationExecutor calls the attestation actions directly via their Go +// API rather than shelling out, so `chainloop trace` can drive a full +// init/add/push cycle in-process. +type AttestationExecutor struct { + actionOpts *ActionsOpts + cliVersion string + localStatePath string + // ownedConn is non-nil when the executor created its own control-plane + // connection (e.g. via WithForcedOrganization). The caller must invoke + // Close to release it. + ownedConn *grpc.ClientConn +} + +type ExecutorOption func(*AttestationExecutor) error + +// NewAttestationExecutor creates an executor from the root command's already +// initialized ActionsOpts. Commands using it must NOT set skipActionOptsInit, +// or base will be nil. cliVersion is recorded in the attestation predicate and +// must be the bare version (cmd.Version), not ActionsOpts.CLIVersion, which +// carries an edition suffix. +func NewAttestationExecutor(base *ActionsOpts, cliVersion string, opts ...ExecutorOption) (*AttestationExecutor, error) { + if base == nil { + return nil, fmt.Errorf("base action options are required") + } + + e := &AttestationExecutor{actionOpts: base, cliVersion: cliVersion} + for _, o := range opts { + if err := o(e); err != nil { + _ = e.Close() + return nil, err + } + } + + return e, nil +} + +// WithLogger overrides the logger used by attestation operations. +// It clones ActionOpts to avoid mutating global state. +func WithLogger(l zerolog.Logger) ExecutorOption { + return func(e *AttestationExecutor) error { + clone := *e.actionOpts + clone.Logger = l + e.actionOpts = &clone + + return nil + } +} + +// WithLocalStatePath sets a file path for local attestation state, +// avoiding remote state and conflicts with other concurrent attestations. +func WithLocalStatePath(path string) ExecutorOption { + return func(e *AttestationExecutor) error { + e.localStatePath = path + + return nil + } +} + +// WithForcedOrganization replaces the control-plane connection with a fresh one +// that carries the given organization name in every request, overriding the +// CLI's default org. Pass an empty string to keep the default connection. +// The new connection is owned by the executor and closed by Close. +func WithForcedOrganization(orgName string) ExecutorOption { + return func(e *AttestationExecutor) error { + if orgName == "" { + return nil + } + + if err := checkTokenOrganization(e.actionOpts.AuthTokenRaw, orgName); err != nil { + return err + } + + conn, err := newControlPlaneConnection(e.actionOpts.AuthTokenRaw, orgName) + if err != nil { + return fmt.Errorf("open forced-org control-plane connection: %w", err) + } + + clone := *e.actionOpts + clone.CPConnection = conn + e.actionOpts = &clone + e.ownedConn = conn + + return nil + } +} + +// checkTokenOrganization fails when the credentials in use are an API token +// minted for an organization other than the pinned one. Org-scoped API tokens +// are the only credentials for which the control plane ignores the +// Chainloop-Organization header (it derives the org from the token instead), +// so without this check the attestation silently lands in the token's org +// rather than the one configured in .chainloop.yml. +// +// Only those tokens carry the org_name claim. User, federated, and +// instance-admin tokens don't, and for all of them the header is honored +// server-side — a user token pinned to an org the user isn't a member of is +// rejected outright — so a missing claim means there is nothing to check. +func checkTokenOrganization(authToken, orgName string) error { + if authToken == "" { + return nil + } + + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(authToken, claims); err != nil { + // ponytail: rejecting a malformed token is the control plane's job, not ours + return nil + } + + tokenOrg, _ := claims["org_name"].(string) + if tokenOrg == "" || strings.EqualFold(tokenOrg, orgName) { + return nil + } + + return fmt.Errorf("credentials belong to organization %q but %q is configured: API tokens are bound to their own organization, so mint a token for %q instead", tokenOrg, orgName, orgName) +} + +// Close releases resources owned by the executor, currently any control-plane +// connection created via WithForcedOrganization. Safe to call multiple times. +func (e *AttestationExecutor) Close() error { + if e.ownedConn == nil { + return nil + } + + conn := e.ownedConn + e.ownedConn = nil + + return conn.Close() +} + +// newControlPlaneConnection dials the control plane using the settings from +// viper (same keys as the upstream root command) and pins the given org name. +func newControlPlaneConnection(authToken, orgName string) (*grpc.ClientConn, error) { + opts := []grpcconn.Option{ + grpcconn.WithInsecure(viper.GetBool("api-insecure")), + grpcconn.WithOrgName(orgName), + } + + if caValue := viper.GetString("control-plane.api-ca"); caValue != "" { + // Mirror upstream behavior: treat as a file path when the file exists, + // otherwise treat it as raw CA content. + if _, err := os.Stat(caValue); err == nil { + opts = append(opts, grpcconn.WithCAFile(caValue)) + } else { + opts = append(opts, grpcconn.WithCAContent(caValue)) + } + } + + return grpcconn.New(viper.GetString("control-plane.API"), authToken, opts...) +} + +// casURI returns the CAS connection URI from viper config. +func casURI() string { return viper.GetString("artifact-cas.API") } + +// casCAPath returns the CAS CA certificate path from viper config. +func casCAPath() string { return viper.GetString("artifact-cas.api-ca") } + +// casInsecure returns whether the CAS connection is insecure from viper config. +func casInsecure() bool { return viper.GetBool("api-insecure") } + +// CheckAuth verifies the CLI can reach the control plane. +func (e *AttestationExecutor) CheckAuth(_ context.Context) error { + _, err := NewConfigCurrentContext(&ActionsOpts{ + CPConnection: e.actionOpts.CPConnection, + Logger: e.actionOpts.Logger, + AuthTokenRaw: e.actionOpts.AuthTokenRaw, + }).Run() + if err != nil { + return fmt.Errorf("chainloop is not authenticated; run 'chainloop config save' first: %w", err) + } + + return nil +} + +// Init starts a new attestation and returns the workflow run ID. +// It uses a local state path to avoid conflicts with concurrent attestations. +// When version is empty the latest project version is used; otherwise the +// attestation targets that specific version. +func (e *AttestationExecutor) Init(ctx context.Context, workflow, project, version string) (string, error) { + a, err := NewAttestationInit(&AttestationInitOpts{ + ActionsOpts: e.actionOpts, + Force: true, + LocalStatePath: e.localStatePath, + CASURI: casURI(), + CASCAPath: casCAPath(), + ConnectionInsecure: casInsecure(), + }) + if err != nil { + return "", fmt.Errorf("create attestation init action: %w", err) + } + + runOpts := &AttestationInitRunOpts{ + WorkflowName: workflow, + ProjectName: project, + Collectors: []string{"aiconfig"}, + } + if version != "" { + runOpts.ProjectVersion = version + } else { + runOpts.UseLatestVersion = true + } + + id, err := a.Run(ctx, runOpts) + if err != nil { + return "", fmt.Errorf("attestation init: %w", err) + } + + return id, nil +} + +// AddEvidence adds an evidence material to the attestation. +// Empty attestation ID is passed upstream so the crafter uses LocalStatePath +// instead of forcing remote state. +func (e *AttestationExecutor) AddEvidence(ctx context.Context, name, filePath string) error { + a, err := NewAttestationAdd(&AttestationAddOpts{ + ActionsOpts: e.actionOpts, + LocalStatePath: e.localStatePath, + CASURI: casURI(), + CASCAPath: casCAPath(), + ConnectionInsecure: casInsecure(), + }) + if err != nil { + return fmt.Errorf("create attestation add action: %w", err) + } + + if _, err := a.Run(ctx, "", name, filePath, "CHAINLOOP_AI_CODING_SESSION", nil, nil, nil); err != nil { + return fmt.Errorf("attestation add evidence: %w", err) + } + + return nil +} + +// Reset cancels the current attestation. +func (e *AttestationExecutor) Reset(ctx context.Context, trigger, reason string) error { + a, err := NewAttestationReset(&AttestationResetOpts{ + ActionsOpts: e.actionOpts, + LocalStatePath: e.localStatePath, + }) + if err != nil { + return fmt.Errorf("create attestation reset action: %w", err) + } + + if err := a.Run(ctx, "", trigger, reason); err != nil { + return fmt.Errorf("attestation reset: %w", err) + } + + return nil +} + +// Push finalizes and pushes the attestation. +func (e *AttestationExecutor) Push(ctx context.Context) error { + cliVersion, cliDigest, err := e.executableInfo() + if err != nil { + return fmt.Errorf("resolve executable info: %w", err) + } + + a, err := NewAttestationPush(&AttestationPushOpts{ + ActionsOpts: e.actionOpts, + LocalStatePath: e.localStatePath, + CASURI: casURI(), + CASCAPath: casCAPath(), + ConnectionInsecure: casInsecure(), + CLIVersion: cliVersion, + CLIDigest: cliDigest, + }) + if err != nil { + return fmt.Errorf("create attestation push action: %w", err) + } + + if _, err := a.Run(ctx, "", nil, false); err != nil { + return fmt.Errorf("attestation push: %w", err) + } + + return nil +} + +// executableInfo returns the CLI version and SHA-256 digest of the running CLI binary +func (e *AttestationExecutor) executableInfo() (string, string, error) { + ex, err := os.Executable() + if err != nil { + return "", "", fmt.Errorf("locating executable: %w", err) + } + + f, err := os.Open(ex) + if err != nil { + return "", "", fmt.Errorf("opening executable: %w", err) + } + defer func() { _ = f.Close() }() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", "", fmt.Errorf("hashing executable: %w", err) + } + + return e.cliVersion, fmt.Sprintf("sha256:%x", h.Sum(nil)), nil +} diff --git a/app/cli/pkg/action/trace_attestation_test.go b/app/cli/pkg/action/trace_attestation_test.go new file mode 100644 index 000000000..16bf15511 --- /dev/null +++ b/app/cli/pkg/action/trace_attestation_test.go @@ -0,0 +1,57 @@ +// +// 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 ( + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckTokenOrganization(t *testing.T) { + apiToken := func(t *testing.T, org string) string { + t.Helper() + claims := jwt.MapClaims{"aud": "api-token-auth.chainloop", "jti": "some-id"} + if org != "" { + claims["org_name"] = org + } + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("secret")) + require.NoError(t, err) + + return s + } + + t.Run("API token from another org is rejected", func(t *testing.T) { + err := checkTokenOrganization(apiToken(t, "foo"), "bar") + require.Error(t, err) + assert.Contains(t, err.Error(), "foo") + assert.Contains(t, err.Error(), "bar") + }) + + t.Run("API token from the pinned org passes", func(t *testing.T) { + assert.NoError(t, checkTokenOrganization(apiToken(t, "bar"), "bar")) + assert.NoError(t, checkTokenOrganization(apiToken(t, "BAR"), "bar")) + }) + + t.Run("tokens without an org claim pass", func(t *testing.T) { + // User and federated tokens: the org header is honored server-side. + assert.NoError(t, checkTokenOrganization(apiToken(t, ""), "bar")) + assert.NoError(t, checkTokenOrganization("", "bar")) + assert.NoError(t, checkTokenOrganization("not-a-jwt", "bar")) + }) +} diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go new file mode 100644 index 000000000..ba8db8540 --- /dev/null +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -0,0 +1,682 @@ +// +// 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 ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/config" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" +) + +// HandleCommitMsgHook appends a Chainloop-Trace-Sessions trailer to the commit +// message when AI sessions have modified files staged for commit. +// Errors are logged but never returned (to avoid blocking commits). +func HandleCommitMsgHook(_ context.Context, msgFilePath string, log zerolog.Logger) error { + if err := handleCommitMsg(msgFilePath, log); err != nil { + log.Debug().Err(err).Msg("commit-msg hook failed") + } + + return nil +} + +func handleCommitMsg(msgFilePath string, log zerolog.Logger) error { + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + if err != nil { + return err + } + + log.Debug().Str("git_dir", gitDir).Msg("commit-msg hook invoked") + + // Skip merge commits — their content is already attributed in the + // merged-in branch's commits. + if tracegit.IsMergeInProgress(gitDir) { + log.Debug().Msg("merge in progress, skipping trailer") + + return nil + } + + attrs, err := state.NewGitStore(gitDir).LoadAllAILineAttributions() + if err != nil { + return fmt.Errorf("load AI line attributions: %w", err) + } + if len(attrs) == 0 { + return nil + } + + client := tracegit.NewGoGitClient() + stagedFiles, err := client.StagedFiles(repoRoot) + if err != nil { + return fmt.Errorf("get staged files: %w", err) + } + + sessionIDs := matchSessionsToFiles(attrs, stagedFiles) + if len(sessionIDs) == 0 { + return nil + } + + log.Debug().Strs("session_ids", sessionIDs).Msg("appending trace sessions trailer") + + return appendTrailer(msgFilePath, sessionIDs) +} + +// matchSessionsToFiles returns session IDs that have AI line attribution for +// any of the given files. The returned slice is sorted. +func matchSessionsToFiles(attrs []*state.AILineAttribution, files []string) []string { + fileSet := make(map[string]struct{}, len(files)) + for _, f := range files { + fileSet[f] = struct{}{} + } + + var sessionIDs []string + for _, attr := range attrs { + for filePath := range attr.Files { + if _, ok := fileSet[filePath]; ok { + sessionIDs = append(sessionIDs, attr.SessionID) + + break + } + } + } + + sort.Strings(sessionIDs) + + return sessionIDs +} + +// appendTrailer reads the commit message file, appends a Chainloop-Trace-Sessions +// trailer with the given session IDs, and writes the file back. +// Skips if the trailer already exists (idempotent for --amend). +func appendTrailer(msgFilePath string, sessionIDs []string) error { + content, err := os.ReadFile(msgFilePath) + if err != nil { + return fmt.Errorf("read commit message: %w", err) + } + + msg := string(content) + + if strings.Contains(msg, state.TrailerKey+":") { + return nil + } + + trailer := fmt.Sprintf("%s: %s\n", state.TrailerKey, strings.Join(sessionIDs, ", ")) + + // Git trailer convention: trailers follow a blank line after the body. + // Ensure we have at least one blank line before the trailer. + trimmed := strings.TrimRight(msg, "\n") + result := trimmed + "\n\n" + trailer + + return os.WriteFile(msgFilePath, []byte(result), 0600) +} + +// HandlePostCommitHook handles the post-commit git hook. +// Errors are logged but never returned (to avoid blocking commits). +func HandlePostCommitHook(ctx context.Context, log zerolog.Logger) error { + if err := handlePostCommit(ctx, log); err != nil { + log.Debug().Err(err).Msg("post-commit hook failed") + } + + return nil +} + +func handlePostCommit(_ context.Context, log zerolog.Logger) error { + gitDir, repoRoot, err := tracegit.FindGitDirAndRoot() + if err != nil { + return err + } + + log.Debug().Str("git_dir", gitDir).Msg("post-commit hook invoked") + + client := tracegit.NewGoGitClient() + sha, message, err := client.CommitHeadInfo(repoRoot) + if err != nil { + return fmt.Errorf("get commit info: %w", err) + } + + // Skip merge commits: their content is already attributed in the + // merged-in branch's commits, so recording them would double-count. + isMerge, err := client.IsMergeCommit(repoRoot, sha) + if err != nil { + return fmt.Errorf("check merge commit: %w", err) + } + if isMerge { + log.Debug().Str("sha", sha).Msg("merge commit, skipping record") + + return nil + } + + // Prefer the trailer's session IDs when present — they survive rebase + // and cherry-pick verbatim. Fall back to file-based derivation otherwise. + store := state.NewGitStore(gitDir) + + sessionIDs := state.ParseSessionIDsFromTrailer(message) + if len(sessionIDs) == 0 { + sessionIDs = deriveSessionIDsForCommit(client, store, repoRoot, sha) + } + + log.Debug().Str("sha", sha).Strs("session_ids", sessionIDs).Msg("recording commit") + + rec := &state.CommitRecord{ + SHA: sha, + Message: message, + SessionIDs: sessionIDs, + Timestamp: state.NowTimestamp(), + } + + if err := store.SaveCommitRecord(rec); err != nil { + return fmt.Errorf("save commit record: %w", err) + } + + log.Info().Str("sha", sha).Msg("commit record saved") + + return nil +} + +// deriveSessionIDsForCommit scans ai-lines data and returns session IDs +// that have modified any file that appears in the given commit. +func deriveSessionIDsForCommit(client tracegit.Client, store *state.Store, repoRoot, headSHA string) []string { + attrs, err := store.LoadAllAILineAttributions() + if err != nil || len(attrs) == 0 { + return nil + } + + changes, err := client.CodeChangesForRange(repoRoot, headSHA, headSHA) + if err != nil || len(changes.Files) == 0 { + return nil + } + + files := make([]string, 0, len(changes.Files)) + for _, f := range changes.Files { + files = append(files, f.Path) + } + + return matchSessionsToFiles(attrs, files) +} + +// filterCurrentBranchCommits returns the subset of records whose SHA is +// reachable from HEAD but not from the default remote branch (i.e. on-branch), +// and which are not merge commits. Records pointing at SHAs that no longer +// exist in the branch — typically left behind by a rebase — are dropped. +// +// If the branch SHA list cannot be obtained, returns the input unchanged +// (best-effort: missing context shouldn't block attestation). +func filterCurrentBranchCommits(client tracegit.Client, repoRoot string, records []*state.CommitRecord, log zerolog.Logger) []*state.CommitRecord { + branchSHAs, err := client.BranchCommitSHAs(repoRoot) + if err != nil { + log.Debug().Err(err).Msg("could not enumerate branch commits, skipping orphan filter") + + return records + } + + inBranch := make(map[string]struct{}, len(branchSHAs)) + for _, sha := range branchSHAs { + inBranch[sha] = struct{}{} + } + + filtered := make([]*state.CommitRecord, 0, len(records)) + for _, rec := range records { + if _, ok := inBranch[rec.SHA]; !ok { + log.Debug().Str("sha", rec.SHA).Msg("dropping orphan commit record (not in branch)") + continue + } + + isMerge, err := client.IsMergeCommit(repoRoot, rec.SHA) + if err != nil { + log.Debug().Err(err).Str("sha", rec.SHA).Msg("could not check merge status, dropping defensively") + continue + } + if isMerge { + log.Debug().Str("sha", rec.SHA).Msg("dropping merge commit record") + continue + } + + filtered = append(filtered, rec) + } + + return filtered +} + +// RunTracePushOpts configures RunTracePush behaviour. +type RunTracePushOpts struct { + // AllowEmpty makes the push attest every recorded session even when + // no AI-attributed commits exist. Used by `chainloop trace run`, + // which drives one-shot agent sessions that may produce no commits. + AllowEmpty bool + + // ProjectName, when set, overrides the projectName read from + // .chainloop.yml. Lets `trace run` operate without mutating the repo + // config. + ProjectName string + // Organization, when set, overrides the organization read from + // .chainloop.yml. + Organization string + // WorkflowName, when set, overrides the workflowName read from + // .chainloop.yml. + WorkflowName string + // ProjectVersion, when set, targets a specific project version for + // the attestation. Empty means use the latest version. + ProjectVersion string + // IgnoreYAML disables the .chainloop.yml fallback used to fill in + // identity fields. `trace run` sets this so its attestations depend + // only on CLI flags. Pre-push hook callers leave it false to keep + // reading the repo config. + IgnoreYAML bool + + // ActionOpts is the root command's initialized options, used to build + // the attestation executor. Required: the push cannot run without it. + ActionOpts *ActionsOpts + // CLIVersion is the bare CLI version recorded in the attestation + // predicate. + CLIVersion string +} + +// HandlePrePushHook handles the pre-push git hook. +// When requireTrace is true, errors from the attestation push are +// propagated so that the git push is blocked. When false, errors are +// logged but never returned. +func HandlePrePushHook(ctx context.Context, requireTrace bool, log zerolog.Logger, opts RunTracePushOpts) error { + drainPushStdin() + + if err := RunTracePush(ctx, log, opts); err != nil { + if requireTrace { + return fmt.Errorf("attestation failed (--require-trace is enabled): %w", err) + } + + log.Debug().Err(err).Msg("pre-push hook failed") + } + + return nil +} + +// RunTracePush gathers AI-coding-session evidence from the local trace +// state and emits a Chainloop attestation. Shared between the pre-push +// git hook and `chainloop trace run`. +// +//nolint:gocyclo // straight-line orchestration of the pre-push flow; splitting it would only move the branches +func RunTracePush(ctx context.Context, log zerolog.Logger, opts RunTracePushOpts) error { + store, repoRoot, err := state.Locate() + if err != nil { + return err + } + + log.Debug().Str("state_dir", store.Dir()).Str("repo_root", repoRoot).Bool("allow_empty", opts.AllowEmpty).Msg("trace push invoked") + + allCommits, err := store.LoadAllCommitRecords() + 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 := range sessionRecords { + 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") + } + + log.Debug().Int("session_count", len(sessionCommits)).Msg("grouped commits by session") + + // Collect repo-level context once (same for all sessions) + rawDir := store.RawSessionDir() + + gitCtx, gitCtxErr := gitClient.Context(repoRoot) + if gitCtxErr != nil { + log.Debug().Err(gitCtxErr).Msg("could not collect git context") + } + + isGenerated := gitClient.GeneratedMatcher(repoRoot) + + // Build evidence for each session + type sessionEvidence struct { + sessionID string + tmpPath string + } + var evidenceFiles []sessionEvidence + + // Register cleanup before the loop so mid-loop returns are covered + defer func() { + for _, ef := range evidenceFiles { + _ = os.Remove(ef.tmpPath) + } + }() + + for sessionID, commits := range sessionCommits { + log.Debug().Str("session", sessionID).Int("commits", len(commits)).Msg("processing session") + + provider := providerForSession(sessionRecords, sessionID) + if provider == nil { + log.Debug().Str("session", sessionID).Msg("no provider registered for session, skipping") + continue + } + + // Fresh copy of session data before parsing — the session-start copy + // may be stale if more conversation happened between start and push. + if err := provider.CopySessionData(store, repoRoot, sessionID); err != nil { + log.Debug().Err(err).Str("session", sessionID).Msg("could not refresh session data") + } + + parseOpts := &trace.ParseOpts{ + SessionDir: rawDir, + SessionID: sessionID, + } + if rec, ok := sessionRecords[sessionID]; ok && rec != nil { + parseOpts.AgentVersion = rec.AgentVersion + parseOpts.Model = rec.Model + } + + result, err := provider.ParseSession(ctx, parseOpts) + if err != nil { + log.Debug().Err(err).Str("session", sessionID).Msg("could not parse session, skipping") + continue + } + + // Apply repo-wide context with per-session commit overrides + if gitCtxErr == nil { + sessionCtx := *gitCtx + sessionCtx.Commits = commitDescriptions(commits) + sessionCtx.CommitCount = len(commits) + if len(commits) > 0 { + sessionCtx.CommitStart = commits[0].SHA + sessionCtx.CommitEnd = commits[len(commits)-1].SHA + } + result.Data.GitContext = &sessionCtx + } + + // Collect code changes scoped to this session's commits. Use a SET-based + // API rather than a SHA range: when the session's commits are non- + // contiguous on the branch (e.g. interleaved with another session or + // human commits), a range diff would over-count by including the gap. + if len(commits) > 0 { + shas := make([]string, 0, len(commits)) + for _, c := range commits { + shas = append(shas, c.SHA) + } + codeChanges, err := gitClient.CodeChangesForCommits(repoRoot, shas) + if err != nil { + log.Debug().Err(err).Msg("could not collect code changes") + } else { + attr := store.LoadAILineAttribution(sessionID) + attribution.FilterGenerated(codeChanges, isGenerated) + attribution.Enrich(sessionID, attr.Files, codeChanges) + result.Data.CodeChanges = codeChanges + } + } + + // Write evidence to temp file + tmpFile, err := os.CreateTemp("", "chainloop-trace-*.json") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + + enc := json.NewEncoder(tmpFile) + enc.SetIndent("", " ") + if err := enc.Encode(result); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) + + return fmt.Errorf("write evidence: %w", err) + } + _ = tmpFile.Close() + + log.Debug().Str("session", sessionID).Msg("generated evidence") + + evidenceFiles = append(evidenceFiles, sessionEvidence{ + sessionID: sessionID, + tmpPath: tmpFile.Name(), + }) + } + + if len(evidenceFiles) == 0 { + log.Debug().Msg("no session evidence could be generated") + + return nil + } + + // Create attestation using local state file to avoid conflicts with other attestations + localStatePath := store.AttestationStatePath() + defer func() { _ = os.Remove(localStatePath) }() + + projectName, organization, workflowName := resolvePushIdentity(repoRoot, opts) + if projectName == "" { + if opts.IgnoreYAML { + return fmt.Errorf("no project name provided") + } + + return fmt.Errorf("no project name found (pass via options or .chainloop.yml)") + } + + log.Debug().Str("project", projectName).Msg("resolved project for trace push") + + execOpts := []ExecutorOption{WithLocalStatePath(localStatePath), WithLogger(log)} + if organization != "" { + log.Debug().Str("forced_org", organization).Msg("forcing organization for trace push") + execOpts = append(execOpts, WithForcedOrganization(organization)) + } + executor, err := NewAttestationExecutor(opts.ActionOpts, opts.CLIVersion, execOpts...) + if err != nil { + // Warn (not debug): a misconfiguration here means no attestation is + // sent at all, and the pre-push hook swallows the returned error + // unless require-trace is on. + log.Warn().Err(err).Msg("skipping trace attestation") + + return err + } + defer func() { _ = executor.Close() }() + + attestationID, err := executor.Init(ctx, workflowName, projectName, opts.ProjectVersion) + if err != nil { + return fmt.Errorf("attestation init: %w", err) + } + + log.Debug().Str("attestation_id", attestationID).Msg("attestation initialized") + + // Add evidence for each session + var addedCount int + for _, ef := range evidenceFiles { + name := evidenceName(ef.sessionID) + if err := executor.AddEvidence(ctx, name, ef.tmpPath); err != nil { + log.Debug().Err(err).Str("session", ef.sessionID).Msg("could not add evidence") + continue + } + addedCount++ + log.Debug().Str("session", ef.sessionID).Str("name", name).Msg("evidence added") + } + + if addedCount == 0 { + log.Debug().Msg("no evidence successfully added, resetting attestation") + _ = executor.Reset(ctx, "trace-push", "no CHAINLOOP_AI_CODING_SESSION evidence added") + + return nil + } + + // Push attestation + log.Debug().Msg("pushing attestation") + if err := executor.Push(ctx); err != nil { + return fmt.Errorf("attestation push: %w", err) + } + + log.Debug().Msg("attestation pushed, wiping single-use trace state") + + // 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. + for _, c := range aiCommits { + if c.Tracked { + continue + } + c.Tracked = true + if err := store.SaveCommitRecord(c); err != nil { + log.Debug().Err(err).Str("sha", c.SHA).Msg("could not mark commit record as tracked") + } + } + + _ = store.WipeTraceDir() + 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") + } + } else { + log.Debug().Err(err).Msg("could not enumerate local branch SHAs; skipping orphan GC") + } + + return nil +} + +// evidenceName returns the material name for a session evidence document. +// Format: ai-coding-session-. Underscores are +// replaced with hyphens because material names may only contain lowercase +// letters, numbers, and hyphens. +func evidenceName(sessionID string) string { + short := sessionID + if len(short) > 6 { + short = short[:6] + } + + return "ai-coding-session-" + strings.ReplaceAll(short, "_", "-") +} + +// commitDescriptions returns commit strings in "SHA first_line_of_message" format. +func commitDescriptions(commits []*state.CommitRecord) []string { + descs := make([]string, len(commits)) + for i, c := range commits { + firstLine, _, _ := strings.Cut(c.Message, "\n") + firstLine = strings.TrimSpace(firstLine) + if firstLine != "" { + descs[i] = c.SHA + " " + firstLine + } else { + descs[i] = c.SHA + } + } + + return descs +} + +// resolvePushIdentity returns the project name, organization, and +// workflow name to use for an attestation push. Opts override +// .chainloop.yml; YAML fields fill in whatever opts left empty unless +// opts.IgnoreYAML is set, in which case the YAML is skipped entirely. +// Workflow always passes through ResolveWorkflowName so the default kicks in. +func resolvePushIdentity(repoRoot string, opts RunTracePushOpts) (project, org, workflow string) { + project = opts.ProjectName + org = opts.Organization + workflow = opts.WorkflowName + + if !opts.IgnoreYAML && (project == "" || org == "" || workflow == "") { + if yml := config.FindChainloopYML(repoRoot); yml != nil { + if project == "" { + project = yml.ProjectName + } + if org == "" { + org = yml.Organization + } + if workflow == "" { + workflow = yml.WorkflowName + } + } + } + + return project, org, config.ResolveWorkflowName(workflow) +} + +// drainPushStdin reads and discards pre-push stdin to avoid SIGPIPE. +func drainPushStdin() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + } +} + +// providerForSession resolves the trace.Provider that owns sessionID. +// Session records written before multi-provider support had no provider +// field and are attributed to trace.DefaultProviderName. +func providerForSession(records map[string]*state.SessionRecord, sessionID string) trace.Provider { + name := trace.DefaultProviderName + + if rec, ok := records[sessionID]; ok && rec != nil && rec.Provider != "" { + return providers.ByName(rec.Provider) + } + + return providers.ByName(name) +} diff --git a/app/cli/pkg/action/trace_hook_handler_test.go b/app/cli/pkg/action/trace_hook_handler_test.go new file mode 100644 index 000000000..76fba30d8 --- /dev/null +++ b/app/cli/pkg/action/trace_hook_handler_test.go @@ -0,0 +1,692 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "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" +) + +func TestEvidenceName(t *testing.T) { + t.Run("truncates long session ID", func(t *testing.T) { + name := evidenceName("abcdefghijklmnop") + assert.Equal(t, "ai-coding-session-abcdef", name) + }) + + t.Run("handles short session ID", func(t *testing.T) { + name := evidenceName("abc") + assert.Equal(t, "ai-coding-session-abc", name) + }) +} + +func TestMatchSessionsToFiles(t *testing.T) { + attrs := []*state.AILineAttribution{ + {SessionID: "session-a", Files: map[string][]aicodingsession.LineRange{"src/foo.go": {{Start: 1, End: 10}}}}, + {SessionID: "session-b", Files: map[string][]aicodingsession.LineRange{"src/bar.go": {{Start: 1, End: 5}}}}, + {SessionID: "session-c", Files: map[string][]aicodingsession.LineRange{"src/baz.go": {{Start: 1, End: 3}}}}, + } + + t.Run("matches sessions that touched staged files", func(t *testing.T) { + ids := matchSessionsToFiles(attrs, []string{"src/foo.go", "src/bar.go"}) + assert.Equal(t, []string{"session-a", "session-b"}, ids) + }) + + t.Run("no match returns nil", func(t *testing.T) { + ids := matchSessionsToFiles(attrs, []string{"src/other.go"}) + assert.Nil(t, ids) + }) + + t.Run("empty staged files returns nil", func(t *testing.T) { + ids := matchSessionsToFiles(attrs, nil) + assert.Nil(t, ids) + }) + + t.Run("empty attrs returns nil", func(t *testing.T) { + ids := matchSessionsToFiles(nil, []string{"src/foo.go"}) + assert.Nil(t, ids) + }) + + t.Run("result is sorted", func(t *testing.T) { + ids := matchSessionsToFiles(attrs, []string{"src/baz.go", "src/foo.go"}) + assert.Equal(t, []string{"session-a", "session-c"}, ids) + }) +} + +func TestAppendTrailer(t *testing.T) { + writeMsg := func(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + + return path + } + + t.Run("appends trailer after blank line", func(t *testing.T) { + path := writeMsg(t, "fix: resolve login bug\n") + require.NoError(t, appendTrailer(path, []string{"id-1", "id-2"})) + + got, _ := os.ReadFile(path) + assert.Equal(t, "fix: resolve login bug\n\nChainloop-Trace-Sessions: id-1, id-2\n", string(got)) + }) + + t.Run("handles multi-line messages", func(t *testing.T) { + path := writeMsg(t, "feat: add auth\n\nThis adds OAuth2 support.\n") + require.NoError(t, appendTrailer(path, []string{"s1"})) + + got, _ := os.ReadFile(path) + assert.Contains(t, string(got), "This adds OAuth2 support.\n\nChainloop-Trace-Sessions: s1\n") + }) + + t.Run("skips if trailer already present", func(t *testing.T) { + original := "fix: bug\n\nChainloop-Trace-Sessions: existing-id\n" + path := writeMsg(t, original) + require.NoError(t, appendTrailer(path, []string{"new-id"})) + + got, _ := os.ReadFile(path) + assert.Equal(t, original, string(got)) + }) + + t.Run("single session ID", func(t *testing.T) { + path := writeMsg(t, "chore: cleanup\n") + require.NoError(t, appendTrailer(path, []string{"only-one"})) + + got, _ := os.ReadFile(path) + assert.Contains(t, string(got), "Chainloop-Trace-Sessions: only-one\n") + }) + + t.Run("strips trailing newlines before adding blank line", func(t *testing.T) { + path := writeMsg(t, "fix: something\n\n\n\n") + require.NoError(t, appendTrailer(path, []string{"s1"})) + + got, _ := os.ReadFile(path) + assert.Equal(t, "fix: something\n\nChainloop-Trace-Sessions: s1\n", string(got)) + }) +} + +// fakeBranchClient is a stub implementation of tracegit.Client for testing +// filterCurrentBranchCommits in isolation. +type fakeBranchClient struct { + branchSHAs []string + branchErr error + mergeSHAs map[string]bool + mergeErr error +} + +func (f *fakeBranchClient) Context(string) (*aicodingsession.GitContext, error) { + return &aicodingsession.GitContext{}, nil +} + +func (f *fakeBranchClient) CodeChanges(string) (*aicodingsession.CodeChanges, error) { + return &aicodingsession.CodeChanges{}, nil +} + +func (f *fakeBranchClient) CodeChangesForRange(string, string, string) (*aicodingsession.CodeChanges, error) { + return &aicodingsession.CodeChanges{}, nil +} + +func (f *fakeBranchClient) CodeChangesForCommits(string, []string) (*aicodingsession.CodeChanges, error) { + return &aicodingsession.CodeChanges{}, nil +} + +func (f *fakeBranchClient) CommitHeadInfo(string) (string, string, error) { return "", "", nil } +func (f *fakeBranchClient) GeneratedMatcher(string) func(string) bool { + return func(string) bool { return false } +} +func (f *fakeBranchClient) StagedFiles(string) ([]string, error) { return nil, nil } +func (f *fakeBranchClient) SnapshotWorktree(string) (map[string]string, error) { + return nil, nil +} +func (f *fakeBranchClient) IsMergeCommit(_, sha string) (bool, error) { + return f.mergeSHAs[sha], f.mergeErr +} + +func (f *fakeBranchClient) BranchCommitSHAs(string) ([]string, error) { + return f.branchSHAs, f.branchErr +} + +func (f *fakeBranchClient) LocalReachableSHAs(string) (map[string]bool, error) { + return nil, nil +} + +func TestFilterCurrentBranchCommits(t *testing.T) { + rec := func(sha string) *state.CommitRecord { return &state.CommitRecord{SHA: sha} } + + t.Run("drops orphans not in branch", func(t *testing.T) { + client := &fakeBranchClient{branchSHAs: []string{"new1", "new2"}} + got := filterCurrentBranchCommits(client, "/repo", + []*state.CommitRecord{rec("old1"), rec("new1"), rec("new2"), rec("old2")}, + zerolog.Nop(), + ) + + require.Len(t, got, 2) + assert.Equal(t, "new1", got[0].SHA) + assert.Equal(t, "new2", got[1].SHA) + }) + + t.Run("drops merge commits even when in branch", func(t *testing.T) { + client := &fakeBranchClient{ + branchSHAs: []string{"a", "merge", "b"}, + mergeSHAs: map[string]bool{"merge": true}, + } + got := filterCurrentBranchCommits(client, "/repo", + []*state.CommitRecord{rec("a"), rec("merge"), rec("b")}, + zerolog.Nop(), + ) + + require.Len(t, got, 2) + assert.Equal(t, "a", got[0].SHA) + assert.Equal(t, "b", got[1].SHA) + }) + + t.Run("returns input unchanged on branch enumeration error", func(t *testing.T) { + client := &fakeBranchClient{branchErr: assert.AnError} + input := []*state.CommitRecord{rec("a"), rec("b")} + got := filterCurrentBranchCommits(client, "/repo", input, zerolog.Nop()) + assert.Equal(t, input, got) + }) +} + +func TestRunTracePushEmpty(t *testing.T) { + t.Run("AllowEmpty=false returns nil when there are no AI commits", func(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + err := RunTracePush(t.Context(), zerolog.Nop(), RunTracePushOpts{}) + require.NoError(t, err, "no commits + AllowEmpty=false must short-circuit cleanly") + }) + + t.Run("AllowEmpty=true returns nil when there are no sessions either", func(t *testing.T) { + _, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + err := RunTracePush(t.Context(), zerolog.Nop(), RunTracePushOpts{AllowEmpty: true}) + require.NoError(t, err, "no commits + no sessions must short-circuit cleanly even under AllowEmpty") + }) +} + +// TestRunTracePush_SkipsWhenAllTracked verifies that the pre-push hook +// short-circuits when every AI-assisted commit on the branch has already +// been marked tracked by a previous successful push. This is the core +// dedup behaviour for back-to-back `git push` (and `git push --tags`) +// invocations that introduce no new commits. +func TestRunTracePush_SkipsWhenAllTracked(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + // Use the SHA of the initial commit so filterCurrentBranchCommits keeps + // the record (it drops SHAs not reachable from HEAD). + sha := headSHA(t, dir) + rec := &state.CommitRecord{ + SHA: sha, + Message: "initial", + SessionIDs: []string{"sess-1"}, + Timestamp: state.NowTimestamp(), + Tracked: true, + } + require.NoError(t, store.SaveCommitRecord(rec)) + + // AllowEmpty=false matches the pre-push hook caller. With every AI + // commit already tracked, RunTracePush must return nil without + // attempting to contact the executor. + err := RunTracePush(t.Context(), zerolog.Nop(), RunTracePushOpts{}) + require.NoError(t, err, "all tracked AI commits must short-circuit cleanly") + + // Record should still be on disk and still marked tracked (we skipped + // before any wipe could run). + got := mustLoadRecord(t, store, sha) + assert.True(t, got.Tracked, "tracked flag must survive the short-circuit") +} + +// TestCommitRecord_TrackedRoundTrip verifies that the new Tracked field +// round-trips through JSON and that legacy records written before this +// change (no `tracked` key on disk) decode as Tracked=false. +func TestCommitRecord_TrackedRoundTrip(t *testing.T) { + gitDir := t.TempDir() + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + t.Run("tracked=true round-trips", func(t *testing.T) { + rec := &state.CommitRecord{ + SHA: "abc", + Message: "m", + SessionIDs: []string{"sess-1"}, + Timestamp: state.NowTimestamp(), + Tracked: true, + } + require.NoError(t, store.SaveCommitRecord(rec)) + + all, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, all, 1) + assert.True(t, all[0].Tracked) + }) + + t.Run("legacy record without tracked key decodes as false", func(t *testing.T) { + legacyDir := t.TempDir() + require.NoError(t, state.NewGitStore(legacyDir).InitTraceDir()) + + raw := `{"sha":"def","message":"legacy","session_ids":["sess-1"],"timestamp":"2026-01-01T00:00:00Z"}` + path := filepath.Join(legacyDir, "chainloop-trace", "commits", "def.json") + require.NoError(t, os.WriteFile(path, []byte(raw), 0600)) + + all, err := state.NewGitStore(legacyDir).LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, all, 1) + assert.False(t, all[0].Tracked, "legacy records must default to untracked") + }) +} + +// initGitRepo creates a real git repo in dir and chdirs into it for the test. +// Restores cwd at teardown. +func initGitRepo(t *testing.T) (dir, gitDir string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not found") + } + + // Resolve symlinks: on macOS t.TempDir() hands back /var/folders/... while + // the repo root resolves to /private/var/folders/..., and the mismatch makes + // the repo-relative paths under test come out as "../.." escapes. + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) + } + run("init", "-b", "main") + run("config", "user.email", "test@test.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0600)) + run("add", "init.txt") + run("commit", "-m", "initial") + + cwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + return dir, filepath.Join(dir, ".git") +} + +// runGit runs git in dir, failing the test on error. +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +} + +func TestHandlePostCommit_UsesTrailerWhenPresent(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + // Make a commit whose message contains a Chainloop-Trace-Sessions trailer + // but with NO matching ai-lines data (so file-based derivation would + // return nothing). The trailer is what carries attribution forward across + // rebase/cherry-pick. + require.NoError(t, os.WriteFile(filepath.Join(dir, "rebased.txt"), []byte("hello"), 0600)) + runGit(t, dir, "add", "rebased.txt") + runGit(t, dir, "commit", "-m", "feat: rebased work\n\nChainloop-Trace-Sessions: orig-1, orig-2\n") + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 1) + assert.Equal(t, []string{"orig-1", "orig-2"}, commits[0].SessionIDs) +} + +func TestHandlePostCommit_SkipsMergeCommit(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + // Create a side branch with one commit, then merge it into main with --no-ff + // to force a merge commit. + runGit(t, dir, "checkout", "-b", "side") + require.NoError(t, os.WriteFile(filepath.Join(dir, "side.txt"), []byte("side"), 0600)) + runGit(t, dir, "add", "side.txt") + runGit(t, dir, "commit", "-m", "side commit") + runGit(t, dir, "checkout", "main") + runGit(t, dir, "merge", "--no-ff", "-m", "merge side", "side") + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Empty(t, commits, "no CommitRecord should be saved for a merge commit") +} + +func TestHandleCommitMsg_SkipsWhenMergeHeadPresent(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + + // Plant ai-lines data so the hook would otherwise append a trailer. + require.NoError(t, store.InitTraceDir()) + require.NoError(t, store.RecordLineRanges("session-x", "init.txt", []aicodingsession.LineRange{{Start: 1, End: 1}})) + + // Plant MERGE_HEAD to simulate a merge in progress. + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "MERGE_HEAD"), []byte("abc\n"), 0600)) + + // Stage a change matching the ai-lines file so matchSessionsToFiles would match. + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("changed"), 0600)) + cmd := exec.Command("git", "add", "init.txt") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + msgPath := filepath.Join(dir, "COMMIT_EDITMSG") + original := "merge: pulling in side\n" + require.NoError(t, os.WriteFile(msgPath, []byte(original), 0600)) + + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + got, err := os.ReadFile(msgPath) + require.NoError(t, err) + assert.Equal(t, original, string(got), "merge commit message should not get a trailer") +} + +// TestRebaseLifecycle exercises the full attribution lifecycle across a rebase +// and a merge using a real git repo. It pins the end-to-end behavior the +// manual verification covers: rebased commits inherit attribution via the +// trailer, old SHA records become orphans that the pre-push filter drops, and +// merge commits never produce records at all. +func TestRebaseLifecycle(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + // detectBaseBranchGoGit looks for refs/remotes/origin/main, so plant it. + runGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + // Plant ai-lines for sess-1 against a.txt so the file-based path also + // works as a fallback. + require.NoError(t, store.RecordLineRanges("sess-1", "a.txt", []aicodingsession.LineRange{{Start: 1, End: 1}})) + + // --- 1. Original commit on a feature branch with the trailer. --- + runGit(t, dir, "checkout", "-b", "feature") + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello\n"), 0600)) + runGit(t, dir, "add", "a.txt") + runGit(t, dir, "commit", "-m", "feat: add a\n\nChainloop-Trace-Sessions: sess-1\n") + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + oldSHA := headSHA(t, dir) + oldRecord := mustLoadRecord(t, store, oldSHA) + require.Equal(t, []string{"sess-1"}, oldRecord.SessionIDs) + + // --- 2. Advance main with an unrelated commit, then cherry-pick the + // original commit on top — simulates rebase replay onto a moved + // base. The new commit has a different parent, hence a new SHA, + // but git preserves the message (and trailer) verbatim. --- + runGit(t, dir, "checkout", "main") + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.txt"), []byte("main moved\n"), 0600)) + runGit(t, dir, "add", "main.txt") + runGit(t, dir, "commit", "-m", "main: move forward") + runGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + runGit(t, dir, "checkout", "-b", "feature-rebased") + runGit(t, dir, "cherry-pick", oldSHA) + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + newSHA := headSHA(t, dir) + require.NotEqual(t, oldSHA, newSHA, "cherry-pick should produce a different SHA") + + newRecord := mustLoadRecord(t, store, newSHA) + assert.Equal(t, []string{"sess-1"}, newRecord.SessionIDs, + "new SHA's record should inherit session IDs from the trailer") + + // Old record still on disk — orphan cleanup is the filter's job, not post-commit's. + all, err := store.LoadAllCommitRecords() + require.NoError(t, err) + assert.Len(t, all, 2) + + // --- 3. The pre-push filter should drop the orphan and keep the new SHA. --- + client := tracegit.NewGoGitClient() + filtered := filterCurrentBranchCommits(client, dir, all, zerolog.Nop()) + require.Len(t, filtered, 1) + assert.Equal(t, newSHA, filtered[0].SHA, "only the in-branch SHA should survive the filter") + + // --- 4. Create and merge a side branch — merge commit must not be recorded. --- + runGit(t, dir, "checkout", "main") + runGit(t, dir, "checkout", "-b", "side") + require.NoError(t, os.WriteFile(filepath.Join(dir, "s.txt"), []byte("side\n"), 0600)) + runGit(t, dir, "add", "s.txt") + runGit(t, dir, "commit", "-m", "side: change") + + runGit(t, dir, "checkout", "feature-rebased") + runGit(t, dir, "merge", "--no-ff", "-m", "merge side", "side") + + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + mergeSHA := headSHA(t, dir) + _, err = os.Stat(filepath.Join(gitDir, "chainloop-trace", "commits", mergeSHA+".json")) + assert.True(t, os.IsNotExist(err), "no record should exist for the merge commit") + + // --- 5. Filter again post-merge: still only the rebased SHA in evidence. --- + all, err = store.LoadAllCommitRecords() + require.NoError(t, err) + filtered = filterCurrentBranchCommits(client, dir, all, zerolog.Nop()) + require.Len(t, filtered, 1) + assert.Equal(t, newSHA, filtered[0].SHA) +} + +// headSHA returns the SHA of HEAD in dir. +func headSHA(t *testing.T, dir string) string { + t.Helper() + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = dir + out, err := cmd.Output() + require.NoError(t, err) + + return strings.TrimSpace(string(out)) +} + +// mustLoadRecord reads the CommitRecord for sha and fails the test if it doesn't exist. +func mustLoadRecord(t *testing.T, store *state.Store, sha string) *state.CommitRecord { + t.Helper() + all, err := store.LoadAllCommitRecords() + require.NoError(t, err) + for _, r := range all { + if r.SHA == sha { + return r + } + } + require.Failf(t, "no CommitRecord found", "sha %s", sha) + + return nil +} + +func TestCommitDescriptions(t *testing.T) { + t.Run("includes first line of message", func(t *testing.T) { + commits := []*state.CommitRecord{ + {SHA: "abc123", Message: "Implement hello world app"}, + {SHA: "def456", Message: "Add credit header"}, + } + descs := commitDescriptions(commits) + assert.Equal(t, []string{ + "abc123 Implement hello world app", + "def456 Add credit header", + }, descs) + }) + + t.Run("uses only first line for multi-line messages", func(t *testing.T) { + commits := []*state.CommitRecord{ + {SHA: "abc123", Message: "First line\n\nMore details here"}, + } + descs := commitDescriptions(commits) + assert.Equal(t, []string{"abc123 First line"}, descs) + }) +} + +// TestPushRebasePushPreservesAIAttribution is the end-to-end regression test +// for PFM-5878. The scenario it captures, in order: +// +// 1. AI session edits a file (ai-lines recorded for sess-1). +// 2. User commits (commit-msg appends the trailer; post-commit fires). +// 3. User pushes — runTracePush attests with AI attribution; the post-push +// wipe + GC must preserve commits/ and ai-lines/ because they're the only +// things tying a future rebase replay back to the original session. +// 4. origin advances on main (a different change pushed elsewhere). +// 5. Feature branch is rebased onto the new origin/main. Each replayed +// commit's post-commit hook reads the trailer and writes a fresh +// CommitRecord under the new SHA. +// 6. User force-pushes — runTracePush must produce a second attestation +// where main.go is again attributed to AI (line ranges + sessionID +// intact), not silently flipped to human. +// +// The test exercises the same code paths the real hooks invoke, but skips +// the actual attestation executor (no Chainloop control plane required). +// Instead it asserts the inputs and outputs of attribution.Enrich after +// the rebase, which is the single point where the bug manifests. +func TestPushRebasePushPreservesAIAttribution(t *testing.T) { + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "5f3b6dd7-f6ca-4639-a65c-493a12cd2844" + + // --- 0. Establish hello.go on main first so the AI commit's diff truly + // shows it as a deletion (not just an absent file on a side branch). --- + require.NoError(t, os.WriteFile(filepath.Join(dir, "hello.go"), []byte("a\nb\nc\nd\ne\nf\ng\n"), 0600)) + runGit(t, dir, "add", "hello.go") + runGit(t, dir, "commit", "-m", "preexisting hello.go") + // detectBaseBranchGoGit looks for refs/remotes/origin/main; plant it. + runGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + // --- 1. AI-edits main.go and records line ranges (one AI line at line 1). --- + // The agent hook would do this in production via state.RecordLineRanges. + require.NoError(t, store.RecordLineRanges(sessionID, "main.go", []aicodingsession.LineRange{{Start: 1, End: 1}})) + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, Provider: "claude", Active: true, StartedAt: state.NowTimestamp(), + })) + + // --- 2. Author the AI commit on a feature branch. main.go is created + // with 8 lines (1 AI + 7 human); hello.go is deleted. The trailer + // binds the commit to sessionID across rebases. --- + runGit(t, dir, "checkout", "-b", "feature") + require.NoError(t, os.Remove(filepath.Join(dir, "hello.go"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("ai-line-1\nh1\nh2\nh3\nh4\nh5\nh6\nh7\n"), 0600)) + runGit(t, dir, "add", "-A") + runGit(t, dir, "commit", "-m", "feat: switch to main\n\nChainloop-Trace-Sessions: "+sessionID+"\n") + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + preRebaseSHA := headSHA(t, dir) + + // Sanity: pre-rebase Enrich attributes main.go to AI. + preChanges := buildSessionCodeChanges(t, store, dir, preRebaseSHA, sessionID) + require.NotZero(t, preChanges.AILinesAdded, "pre-rebase: main.go must be attributed to AI") + require.Equal(t, 1, preChanges.AILinesAdded, "pre-rebase: only the line covered by ai-lines is AI") + requireFileAttribution(t, preChanges, "main.go", trace.AttributionAI, []string{sessionID}) + requireFileAttribution(t, preChanges, "hello.go", trace.AttributionHuman, nil) + + // --- 3. Simulate the post-push wipe + reachability GC that runs at the + // end of runTracePush. ai-lines/ and commits/ for sessionID must + // survive because the SHA is reachable from feature. --- + require.NoError(t, store.WipeTraceDir()) + gitClient := tracegit.NewGoGitClient() + liveSHAs, err := gitClient.LocalReachableSHAs(dir) + require.NoError(t, err) + require.NotEmpty(t, liveSHAs, "post-push GC must see reachable SHAs") + require.NoError(t, store.GCOrphans(liveSHAs)) + + require.NotEmpty(t, store.LoadAILineAttribution(sessionID).Files, + "post-push: ai-lines for the live session must survive the wipe + GC") + + // --- 4. origin/main advances with an unrelated commit. --- + runGit(t, dir, "checkout", "main") + require.NoError(t, os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("upstream change\n"), 0600)) + runGit(t, dir, "add", "unrelated.txt") + runGit(t, dir, "commit", "-m", "main: upstream change") + runGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + // --- 5. Rebase the feature branch onto the new main. Cherry-pick is the + // deterministic stand-in for `git rebase` — same effect on hooks + // (post-commit fires per replayed commit) and same trailer + // preservation. --- + runGit(t, dir, "checkout", "-b", "feature-rebased") + runGit(t, dir, "cherry-pick", preRebaseSHA) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + postRebaseSHA := headSHA(t, dir) + require.NotEqual(t, preRebaseSHA, postRebaseSHA, "rebase must produce a new SHA") + + postRec := mustLoadRecord(t, store, postRebaseSHA) + require.Equal(t, []string{sessionID}, postRec.SessionIDs, + "post-rebase CommitRecord must inherit sessionID from the trailer") + + // --- 6. Push 2: the second attestation must again attribute main.go to AI. --- + postChanges := buildSessionCodeChanges(t, store, dir, postRebaseSHA, sessionID) + require.Equal(t, 1, postChanges.AILinesAdded, + "PFM-5878 regression: post-rebase attribution must match pre-rebase, not flip to human") + require.Equal(t, preChanges.LinesAdded, postChanges.LinesAdded, "diff size must be stable across rebase") + require.Equal(t, preChanges.LinesRemoved, postChanges.LinesRemoved) + requireFileAttribution(t, postChanges, "main.go", trace.AttributionAI, []string{sessionID}) + requireFileAttribution(t, postChanges, "hello.go", trace.AttributionHuman, nil) +} + +// buildSessionCodeChanges runs the same diff-and-enrich pipeline runTracePush +// uses for one session: aggregate the session's commits via +// CodeChangesForCommits, then layer on AI line attribution from ai-lines/. +// Returns the resulting CodeChanges so the caller can assert on it. +func buildSessionCodeChanges(t *testing.T, store *state.Store, dir, sha, sessionID string) *aicodingsession.CodeChanges { + t.Helper() + client := tracegit.NewGoGitClient() + cc, err := client.CodeChangesForCommits(dir, []string{sha}) + require.NoError(t, err) + attr := store.LoadAILineAttribution(sessionID) + attribution.Enrich(sessionID, attr.Files, cc) + + return cc +} + +// requireFileAttribution finds path in changes.Files and asserts attribution +// + sessionIDs match. wantSessionIDs may be nil when no session owns the file. +func requireFileAttribution(t *testing.T, changes *aicodingsession.CodeChanges, path, wantAttr string, wantSessionIDs []string) { + t.Helper() + for _, f := range changes.Files { + if f.Path != path { + continue + } + assert.Equal(t, wantAttr, f.Attribution, "attribution for %s", path) + assert.Equal(t, wantSessionIDs, f.SessionIDs, "session IDs for %s", path) + + return + } + require.Failf(t, "file not found in CodeChanges", "%s (got %v)", path, changes.Files) +} diff --git a/app/cli/pkg/action/trace_run.go b/app/cli/pkg/action/trace_run.go new file mode 100644 index 000000000..2d1bfdd93 --- /dev/null +++ b/app/cli/pkg/action/trace_run.go @@ -0,0 +1,324 @@ +// +// 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" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "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/state" + "github.com/rs/zerolog" +) + +// SubprocessExitError carries the exit code of a wrapped command so the +// CLI can propagate it to the parent shell. +type SubprocessExitError struct { + Command string + ExitCode int +} + +func (e *SubprocessExitError) Error() string { + return fmt.Sprintf("%s exited with status %d", e.Command, e.ExitCode) +} + +// TraceRunOpts configures a single TraceRun invocation. +type TraceRunOpts struct { + // Store owns the chainloop-trace state directory, parented by the + // .git directory inside a repository and otherwise by the out-of-tree + // directory returned by state.NonGitDir. Outside a repository (IsGit + // false) the managed git hooks are disabled, since git could never + // invoke them anyway. + Store *state.Store + // RepoRoot is the repository root containing .chainloop.yml, or the + // working directory when running outside a git repository. + RepoRoot string + // Providers is the list of agent provider names (e.g. "claude-code", + // "cursor") whose info-gathering hooks should be installed. + Providers []string + // Command is the wrapped command: Command[0] is the program, + // Command[1:] are its arguments. + Command []string + // ProjectName, Organization, WorkflowName identify the attestation + // for this run. trace run is isolated from .chainloop.yml, so callers + // must pass these values directly from CLI flags. + ProjectName string + Organization string + WorkflowName string + // ProjectVersion, when set, targets a specific project version. + // Empty means use the latest version. + ProjectVersion string + + // ActionOpts is the root command's initialized options, used to build + // the attestation executor. Required. + ActionOpts *ActionsOpts + // CLIVersion is the bare CLI version recorded in the attestation + // predicate. + CLIVersion string +} + +// TraceRun wraps a single-shot agent invocation: it cleans any prior +// trace state, installs the trace-run subset of git and agent hooks, +// runs the wrapped command, then attests the session and tears +// everything down. Errors from the wrapped command are surfaced as +// SubprocessExitError so the caller can propagate the exit code. +func TraceRun(ctx context.Context, log zerolog.Logger, opts TraceRunOpts) error { + if len(opts.Command) == 0 { + return fmt.Errorf("no command to run") + } + + selectedProviders := providers.ByNames(opts.Providers) + if len(selectedProviders) == 0 { + 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 + } + if err := executor.CheckAuth(ctx); err != nil { + log.Warn().Err(err).Msg("authentication check failed; attestation will fail after the session") + } + if err := executor.Close(); err != nil { + log.Debug().Err(err).Msg("closing auth-check executor") + } + + // Snapshot the agent settings files before we touch anything else + // so we can restore them bit-for-bit on exit, even if the user had + // pre-existing customisations. + backups := snapshotAgentSettings(selectedProviders, opts.RepoRoot, log) + + // Cleanup must precede restoreAgentSettings: it strips every chainloop hook + // entry from the agent settings files, undoing the restore otherwise. Its + // errors are logged inside CleanupTrace and ignored here, because failing + // trace run on partial cleanup is worse than surfacing the warnings. + // Outside a repository the state directory is trace run's alone — + // `chainloop trace init` only ever targets a .git directory — so + // leftovers from a killed run are always ours to reclaim. + ownsState := !opts.Store.IsGit() || traceRunOwnsState(opts.Store) + if ownsState { + _ = CleanupTrace(opts.Store, opts.RepoRoot, log) + } + defer func() { + if ownsState { + _ = CleanupTrace(opts.Store, opts.RepoRoot, log) + } else if err := opts.Store.ClearTraceRunActive(); err != nil { + log.Warn().Err(err).Msg("could not clear trace-run sentinel") + } + restoreAgentSettings(backups, log) + }() + + if err := opts.Store.InitTraceDir(); err != nil { + return fmt.Errorf("create trace directory: %w", err) + } + + // Outside a git repository there is nothing to invoke the git hooks. + if opts.Store.IsGit() { + hooksDir, err := hooks.Install(opts.Store.GitDir(), true) + if err != nil { + return err + } + log.Debug().Str("path", hooksDir).Msg("git hooks installed (commit-msg, post-commit)") + } + + for _, p := range selectedProviders { + if err := p.InstallHooksForTraceRun(opts.RepoRoot); err != nil { + log.Warn().Err(err).Str("provider", p.Name()).Msg("could not install agent hooks") + continue + } + log.Debug().Str("provider", p.Name()).Msg("agent hooks installed") + } + + if err := opts.Store.MarkTraceInitialized(); err != nil { + return fmt.Errorf("mark trace initialized: %w", err) + } + + if err := opts.Store.MarkTraceRunActive(); err != nil { + return fmt.Errorf("mark trace run active: %w", err) + } + + // The wrapped command is supplied by the user on their own machine + // (`chainloop trace run -- `); running it verbatim is the feature and + // crosses no trust boundary. + //nolint:gosec // see above: running the user-supplied command verbatim is the feature + sub := exec.CommandContext(ctx, opts.Command[0], opts.Command[1:]...) // nosemgrep + sub.Stdin = os.Stdin + sub.Stdout = os.Stdout + sub.Stderr = os.Stderr + + log.Debug().Strs("command", opts.Command).Msg("running wrapped command") + + if err := sub.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &SubprocessExitError{Command: opts.Command[0], ExitCode: exitErr.ExitCode()} + } + + return fmt.Errorf("run %s: %w", opts.Command[0], err) + } + + log.Debug().Msg("wrapped command completed; attesting session") + + return RunTracePush(ctx, log, RunTracePushOpts{ + AllowEmpty: true, + ProjectName: opts.ProjectName, + Organization: opts.Organization, + WorkflowName: opts.WorkflowName, + ProjectVersion: opts.ProjectVersion, + IgnoreYAML: true, + ActionOpts: opts.ActionOpts, + CLIVersion: opts.CLIVersion, + }) +} + +// traceRunOwnsState reports whether the store's trace state is trace run's +// to wipe. A pre-existing install (from trace init or an agent hook's +// auto-install) owns the git hooks and the trace state dir, which holds +// unpushed AI attribution, so trace run must leave it intact. Leftover state +// from a dead trace run still carries the run-active sentinel and is ours. +func traceRunOwnsState(store *state.Store) bool { + return !store.IsTraceInitialized() || store.IsTraceRunActive() +} + +// settingsBackup captures a provider's settings file before trace run +// mutates it, so the file can be restored verbatim on exit. +type settingsBackup struct { + path string + content []byte + mode os.FileMode + existed bool + dirExisted bool +} + +// snapshotAgentSettings reads each selected provider's settings file +// into memory. A missing file is recorded so the restore step can +// remove anything install created. Read failures are warned and the +// provider is dropped from the backup list (best-effort). +func snapshotAgentSettings(provs []trace.Provider, repoRoot string, log zerolog.Logger) []settingsBackup { + backups := make([]settingsBackup, 0, len(provs)) + for _, p := range provs { + b := settingsBackup{path: p.SettingsFile(repoRoot)} + b.dirExisted = dirExists(filepath.Dir(b.path)) + info, err := os.Stat(b.path) + switch { + case err == nil: + content, rerr := os.ReadFile(b.path) + if rerr != nil { + log.Warn().Err(rerr).Str("path", b.path).Msg("could not back up agent settings; install will proceed without restore") + continue + } + b.existed = true + b.content = content + b.mode = info.Mode().Perm() + case errors.Is(err, os.ErrNotExist): + // File didn't exist — restore will delete whatever install creates. + default: + log.Warn().Err(err).Str("path", b.path).Msg("could not stat agent settings; install will proceed without restore") + continue + } + backups = append(backups, b) + } + + return backups +} + +// restoreAgentSettings writes each backed-up settings file back to its +// original path (with original mode), or removes the file if it didn't +// exist before snapshotting. If trace run created the parent directory +// (it did not exist at snapshot time), the directory is also removed +// once empty so trace run leaves no on-disk trace. +func restoreAgentSettings(backups []settingsBackup, log zerolog.Logger) { + for _, b := range backups { + if b.existed { + if err := os.WriteFile(b.path, b.content, b.mode); err != nil { + log.Warn().Err(err).Str("path", b.path).Msg("could not restore agent settings backup") + } + continue + } + if err := os.Remove(b.path); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Warn().Err(err).Str("path", b.path).Msg("could not remove agent settings file created by trace run") + continue + } + if b.dirExisted { + continue + } + // Parent directory didn't exist before the run — install created + // it. Remove it now; os.Remove fails with ENOTEMPTY when other + // content has appeared, which is the no-op we want there. + _ = os.Remove(filepath.Dir(b.path)) + } +} + +// dirExists reports whether path resolves to an existing directory. +// Returns false on any error (missing, permission denied, not-a-dir). +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// CleanupTrace removes the store's chainloop-trace state, the managed git +// hooks, and every known provider's agent hooks. A store outside a repository +// had no git hooks installed in the first place, and the state directory itself +// is trace run's to remove. Every step runs even when an earlier one failed so +// partial cleanup still progresses; the joined error is returned for callers +// that need to fail loudly (e.g. trace uninstall). Callers that want +// best-effort cleanup (e.g. trace run's defer) can ignore the result. +func CleanupTrace(store *state.Store, repoRoot string, log zerolog.Logger) error { + var errs []error + + if store.IsGit() { + if hooksDir, err := hooks.Uninstall(store.GitDir()); err != nil { + log.Warn().Err(err).Msg("could not remove git hooks") + errs = append(errs, fmt.Errorf("remove git hooks: %w", err)) + } else { + log.Debug().Str("path", hooksDir).Msg("git hooks removed") + } + } + + if err := store.RemoveTraceDir(); err != nil { + log.Warn().Err(err).Msg("could not remove trace directory") + errs = append(errs, fmt.Errorf("remove trace directory: %w", err)) + } + + // The out-of-tree state directory is ours alone, so drop it too rather + // than leaving one empty directory per working directory behind. + // os.Remove fails with ENOTEMPTY when anything survived, which is the + // no-op we want there. + if !store.IsGit() { + _ = os.Remove(store.Dir()) + } + + for _, p := range providers.All() { + if err := p.UninstallHooks(repoRoot); err != nil { + log.Warn().Err(err).Str("provider", p.Name()).Msg("could not remove agent hooks") + errs = append(errs, fmt.Errorf("uninstall %s hooks: %w", p.Name(), err)) + continue + } + log.Debug().Str("provider", p.Name()).Msg("agent hooks removed") + } + + return errors.Join(errs...) +} diff --git a/app/cli/pkg/action/trace_run_test.go b/app/cli/pkg/action/trace_run_test.go new file mode 100644 index 000000000..31d2d40fa --- /dev/null +++ b/app/cli/pkg/action/trace_run_test.go @@ -0,0 +1,190 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTraceRunOwnsState(t *testing.T) { + t.Run("clean repo", func(t *testing.T) { + assert.True(t, traceRunOwnsState(state.NewGitStore(t.TempDir()))) + }) + + t.Run("live trace init install is not ours", func(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.MarkTraceInitialized()) + + assert.False(t, traceRunOwnsState(store), "trace run must not wipe a trace init setup") + }) + + t.Run("leftover state from a dead trace run is ours", func(t *testing.T) { + store := state.NewGitStore(t.TempDir()) + require.NoError(t, store.MarkTraceInitialized()) + require.NoError(t, store.MarkTraceRunActive()) + + assert.True(t, traceRunOwnsState(store)) + }) +} + +// TestCleanupTraceNoGit pins the contract `chainloop trace run` relies on +// outside a git repository: an empty gitDir removes the whole out-of-tree state +// directory and never touches a hooks/ directory. +func TestCleanupTraceNoGit(t *testing.T) { + repoRoot := t.TempDir() + stateDir := filepath.Join(t.TempDir(), "state") + store := state.NewOutOfTreeStore(stateDir) + require.NoError(t, store.InitTraceDir()) + + // A hooks/ directory that hooks.Uninstall would have rewritten. + hooksDir := filepath.Join(stateDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0755)) + sentinel := filepath.Join(hooksDir, "pre-push") + require.NoError(t, os.WriteFile(sentinel, []byte("#!/bin/sh\nexit 0\n"), 0600)) + + require.NoError(t, CleanupTrace(store, repoRoot, zerolog.Nop())) + + _, err := os.Stat(filepath.Join(stateDir, "chainloop-trace")) + assert.True(t, os.IsNotExist(err), "trace state dir should be removed") + + got, err := os.ReadFile(sentinel) + require.NoError(t, err, "hooks/ must be left alone when there is no git dir") + assert.Equal(t, "#!/bin/sh\nexit 0\n", string(got)) +} + +// TestCleanupTraceNoGitRemovesEmptyStateDir covers the tidy-up half: with +// nothing else inside, the per-directory state dir itself goes too. +func TestCleanupTraceNoGitRemovesEmptyStateDir(t *testing.T) { + stateDir := filepath.Join(t.TempDir(), "state") + store := state.NewOutOfTreeStore(stateDir) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, CleanupTrace(store, t.TempDir(), zerolog.Nop())) + + _, err := os.Stat(stateDir) + assert.True(t, os.IsNotExist(err), "empty state dir should be removed") +} + +// The removal above must never reach a repository: inside one the same +// directory is .git. The Store decides, so an empty .git cannot be mistaken +// for a spent out-of-tree directory. +func TestCleanupTraceKeepsGitDir(t *testing.T) { + gitDir := filepath.Join(t.TempDir(), ".git") + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + require.NoError(t, CleanupTrace(store, t.TempDir(), zerolog.Nop())) + + _, err := os.Stat(gitDir) + require.NoError(t, err, ".git must survive cleanup even with nothing left inside it") +} + +func TestSnapshotAndRestoreAgentSettings(t *testing.T) { + t.Run("existing file is restored verbatim", func(t *testing.T) { + repoRoot := t.TempDir() + p := claude.New() + path := p.SettingsFile(repoRoot) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + original := []byte(`{"theme":"dark","custom":42}`) + require.NoError(t, os.WriteFile(path, original, 0o600)) + + backups := snapshotAgentSettings([]trace.Provider{p}, repoRoot, zerolog.Nop()) + require.Len(t, backups, 1) + + require.NoError(t, os.WriteFile(path, []byte(`{"chainloop":"hooks"}`), 0o600)) + + restoreAgentSettings(backups, zerolog.Nop()) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, original, got, "restore must produce a bit-for-bit copy of the original file") + }) + + t.Run("missing file stays missing after restore", func(t *testing.T) { + repoRoot := t.TempDir() + p := claude.New() + path := p.SettingsFile(repoRoot) + + backups := snapshotAgentSettings([]trace.Provider{p}, repoRoot, zerolog.Nop()) + require.Len(t, backups, 1) + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"chainloop":"hooks"}`), 0o600)) + + restoreAgentSettings(backups, zerolog.Nop()) + + _, err := os.Stat(path) + assert.True(t, os.IsNotExist(err), "restore must delete files the run created") + + _, err = os.Stat(filepath.Dir(path)) + assert.True(t, os.IsNotExist(err), "restore must also remove the parent directory if it became empty") + }) + + t.Run("pre-existing empty parent directory is preserved", func(t *testing.T) { + repoRoot := t.TempDir() + p := claude.New() + path := p.SettingsFile(repoRoot) + dir := filepath.Dir(path) + + // User pre-created an empty .claude/ directory. + require.NoError(t, os.MkdirAll(dir, 0o755)) + + backups := snapshotAgentSettings([]trace.Provider{p}, repoRoot, zerolog.Nop()) + require.Len(t, backups, 1) + assert.True(t, backups[0].dirExisted, "snapshot must record that the parent dir existed before") + + // Simulate trace run dropping a settings file into the dir. + require.NoError(t, os.WriteFile(path, []byte(`{"chainloop":"hooks"}`), 0o600)) + + restoreAgentSettings(backups, zerolog.Nop()) + + _, err := os.Stat(path) + assert.True(t, os.IsNotExist(err), "restore must delete the settings file") + info, err := os.Stat(dir) + require.NoError(t, err, "restore must preserve a directory that existed before the run") + assert.True(t, info.IsDir()) + }) + + t.Run("parent directory with sibling content is preserved", func(t *testing.T) { + repoRoot := t.TempDir() + p := claude.New() + path := p.SettingsFile(repoRoot) + sibling := filepath.Join(filepath.Dir(path), "CLAUDE.md") + + backups := snapshotAgentSettings([]trace.Provider{p}, repoRoot, zerolog.Nop()) + require.Len(t, backups, 1) + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(sibling, []byte(`# notes`), 0o600)) + require.NoError(t, os.WriteFile(path, []byte(`{"chainloop":"hooks"}`), 0o600)) + + restoreAgentSettings(backups, zerolog.Nop()) + + _, err := os.Stat(path) + assert.True(t, os.IsNotExist(err), "restore must delete the settings file") + _, err = os.Stat(sibling) + assert.NoError(t, err, "restore must leave unrelated sibling files alone") + }) +} diff --git a/go.mod b/go.mod index a5956f804..e67e92aa9 100644 --- a/go.mod +++ b/go.mod @@ -108,8 +108,10 @@ require github.com/vektah/gqlparser/v2 v2.5.36 require ( github.com/XSAM/otelsql v0.43.0 github.com/betterleaks/betterleaks v1.8.1 + github.com/go-git/go-billy/v6 v6.0.0-alpha.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/moby/moby/api v1.55.0 + github.com/sergi/go-diff v1.4.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 go.opentelemetry.io/otel/sdk v1.45.0 @@ -188,7 +190,6 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/go-git/go-billy/v6 v6.0.0-alpha.2 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect @@ -275,7 +276,6 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.2.1 // indirect - github.com/sergi/go-diff v1.4.0 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect