Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions app/cli/cmd/trace_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
package cmd

import (
"context"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/config"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/cursor"
Expand Down Expand Up @@ -183,13 +186,37 @@ func newTraceHookClaudeSessionStartCmd() *cobra.Command {
Annotations: map[string]string{
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
InitHookLogger()
return action.HandleAgentSessionStart(claude.New(), logger)
RunE: func(cmd *cobra.Command, _ []string) error {
return runAgentSessionStart(cmd, claude.New())
},
}
}

// runAgentSessionStart handles the session-start hook of any agent. In
// control-plane mode, the hook asks the control plane whether the credentials
// are accepted, with the same check as `chainloop trace init`, so that the
// session start can report a credentials problem before the push.
func runAgentSessionStart(cmd *cobra.Command, provider trace.Provider) error {
InitHookLogger()

checkAuth := func(ctx context.Context, organization string) error {
// This command is skipActionOptsInit, so root did not build the
// control-plane connection. A local-mode session never gets here.
if err := ensureActionOpts(cmd); err != nil {
return err
}

executor, err := openTraceExecutor(ctx, organization)
if err != nil {
return err
}

return executor.Close()
}

return action.HandleAgentSessionStart(provider, checkAuth, logger)
}

func newTraceHookClaudeUserPromptSubmitCmd() *cobra.Command {
return &cobra.Command{
Use: "user-prompt-submit",
Expand Down Expand Up @@ -270,9 +297,8 @@ func newTraceHookCursorSessionStartCmd() *cobra.Command {
Annotations: map[string]string{
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
InitHookLogger()
return action.HandleAgentSessionStart(cursor.New(), logger)
RunE: func(cmd *cobra.Command, _ []string) error {
return runAgentSessionStart(cmd, cursor.New())
},
}
}
Expand Down Expand Up @@ -364,9 +390,8 @@ func newTraceHookOpenCodeSessionStartCmd() *cobra.Command {
Annotations: map[string]string{
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
InitHookLogger()
return action.HandleAgentSessionStart(opencode.New(), logger)
RunE: func(cmd *cobra.Command, _ []string) error {
return runAgentSessionStart(cmd, opencode.New())
},
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ What the user sees in the agent.
|---------|---------------|-------------|--------|--------------|------------|
| Welcome message | At session start, the user sees a message that tells that Chainloop records the session, and where the evidence goes: the Chainloop project, or the on-disk path in local mode (spec issue-3582). | Yes | No | No | No |
| Session link after push | After a `git push` that the agent runs, the user sees a link to the session in Chainloop. | Yes | No | No | No |
| Credentials warning | At session start, in control-plane mode, the user is told when the control plane does not accept the CLI credentials (for example, an expired token), so the push will not upload the session. The hook uses the same check as `chainloop trace init`. An unreachable control plane, or a check that takes more than 2 seconds, shows no warning. | Yes | Partial | Partial | Partial |

Why not Yes:

- **Welcome message, Cursor and OpenCode:** the provider gives the session-start context to the model only. It has no channel that shows a message to the user at session start, so the welcome message is dropped.
- **Credentials warning, Cursor and OpenCode:** the provider has no channel to the user at session start. The model is told to tell the user, so the warning depends on the model.
- **Session link after push, Cursor:** the `postToolUse` hook runs after a shell command, but its response has no field that Cursor shows to the user. The link stays pending until it expires.
- **Session link after push, OpenCode:** the plugin hook fires after a shell command, but the response that shows a message to the user is not verified yet. The link stays pending until it expires.

Expand Down
4 changes: 4 additions & 0 deletions app/cli/pkg/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ const (
// first prompt. Missing the banner's destination line costs far less than
// a visible stall, so this gives up quickly.
hookDashboardURLTimeout = 2 * time.Second

// hookAuthCheckTimeout bounds the credentials check of the session-start
// hook, for the same reason. A check that takes longer shows no warning.
hookAuthCheckTimeout = 2 * time.Second
)

// AttestationStatePath returns the resolved path for local attestation state.
Expand Down
92 changes: 83 additions & 9 deletions app/cli/pkg/action/trace_agent_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,68 @@ func localSessionStartBanner(tracePath string, traceRunActive bool) string {
return banner
}

// HandleAgentSessionStart handles the agent session-start hook.
func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error {
// TraceAuthCheck asks the control plane whether the CLI credentials are
// accepted for the organization. An empty organization uses the default one.
type TraceAuthCheck func(ctx context.Context, organization string) error

// sessionAuthProblem runs checkAuth and returns the message the CLI shows for
// an authentication failure, or an empty string. The control plane decides
// whether the credentials are valid. Any other failure, for example an
// unreachable control plane or a check that takes longer than the timeout,
// returns an empty string: the hook does not guess.
func sessionAuthProblem(checkAuth TraceAuthCheck, organization string, log zerolog.Logger) string {
if checkAuth == nil {
return ""
}

ctx, cancel := context.WithTimeout(context.Background(), hookAuthCheckTimeout)
defer cancel()

// The check can ignore ctx, so it runs in its own goroutine. The hook
// process exits soon after, so an abandoned check stops with it.
done := make(chan error, 1)
go func() { done <- checkAuth(ctx, organization) }()

var err error
select {
case err = <-done:
case <-ctx.Done():
log.Debug().Msg("session-start: the auth check timed out")
return ""
}

msg, isAuth := AuthErrorMessage(err)
if !isAuth {
if err != nil {
log.Debug().Err(err).Msg("session-start: the auth check failed")
}

return ""
}

return msg
}

// authProblemBanner tells the developer that the session will not be uploaded
// because the control plane does not accept the credentials. The push is the
// first step that uses them, and it comes after the session work is done.
func authProblemBanner(authMsg string) string {
return "Chainloop Trace cannot upload this session when you push: " + authMsg + "."
}

// authProblemInstruction is the authProblemBanner for an agent that has no
// banner. The model is the only channel to the developer.
func authProblemInstruction(authMsg string) string {
return "Chainloop Trace cannot upload this session when the user pushes, because the Chainloop CLI credentials are not accepted: " +
authMsg + ". Tell the user now, in one line, to fix this before they push."
}

// HandleAgentSessionStart handles the agent session-start hook. In
// control-plane mode, checkAuth asks the control plane whether the credentials
// that the pre-push hook will use are accepted, so that the developer knows
// about a problem before the session work is done. A nil checkAuth skips the
// check.
func HandleAgentSessionStart(provider trace.Provider, checkAuth TraceAuthCheck, 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")
Expand Down Expand Up @@ -201,18 +261,29 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error
// discards it would make the developer pay the wait for nothing.
var msg trace.SessionStartMessage

// A `trace run` session takes its mode from the run. Other sessions take
// it from traceStorage in .chainloop.yml.
local := config.IsLocalStorage(repoRoot)
if store.IsTraceRunActive() {
local = store.IsTraceRunLocal()
}

// Local mode uploads nothing, so only the control-plane mode needs
// credentials.
var authMsg string
if !local {
authMsg = sessionAuthProblem(checkAuth, config.LoadOrganizationFromYML(repoRoot), log)
Comment thread
jiparis marked this conversation as resolved.
}

if provider.SupportsSessionStartInstruction() {
msg.Instruction = sessionSpecInstruction(repoRoot, input.SessionID, provider.SupportsPromptReminder(), log)
// An agent with a banner tells the developer directly.
if authMsg != "" && !provider.SupportsSessionStartBanner() {
msg.Instruction = strings.TrimSpace(authProblemInstruction(authMsg) + "\n\n" + msg.Instruction)
}
}

if provider.SupportsSessionStartBanner() {
// A `trace run` session takes its mode from the run. Other sessions
// take it from traceStorage in .chainloop.yml.
local := config.IsLocalStorage(repoRoot)
if store.IsTraceRunActive() {
local = store.IsTraceRunLocal()
}

var banner string
if local {
// Local mode reaches no control plane, so the banner names the
Expand All @@ -227,6 +298,9 @@ func HandleAgentSessionStart(provider trace.Provider, log zerolog.Logger) error
config.LoadOrganizationFromYML(repoRoot),
repositoryconfig.LoadProjectFromYML(repoRoot),
)
if authMsg != "" {
banner += "\n\n" + authProblemBanner(authMsg)
}
}
msg.Banner = "\n\n" + banner + "\n"
}
Expand Down
24 changes: 12 additions & 12 deletions app/cli/pkg/action/trace_agent_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestHandleAgentSessionStart(t *testing.T) {

withStdin(t, `{"session_id":"abc-123","cwd":"/some/path"}`)

require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))

assert.True(t, store.SessionRecordExists("abc-123"))
})
Expand All @@ -83,7 +83,7 @@ func TestHandleAgentSessionStart(t *testing.T) {

withStdin(t, `{"session_id":"abc-123"}`)

require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))

assert.True(t, store.SessionRecordExists("abc-123"))
})
Expand All @@ -104,7 +104,7 @@ func TestHandleAgentSessionStart(t *testing.T) {

// Start a new session
withStdin(t, `{"session_id":"session-2"}`)
require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))

// Both sessions should have records
assert.True(t, store.SessionRecordExists("session-1"))
Expand All @@ -123,7 +123,7 @@ func TestHandleAgentSessionStart(t *testing.T) {

withStdin(t, `{"session_id":"msg-test","cwd":"/some/path"}`)
stdout := captureStdout(t, func() {
require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))
})

assert.Contains(t, stdout, `"systemMessage"`)
Expand Down Expand Up @@ -160,7 +160,7 @@ func TestHandleAgentSessionStart(t *testing.T) {

withStdin(t, `{"session_id":"mode-test"}`)
stdout := captureStdout(t, func() {
require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))
})

if tc.wantLocal {
Expand All @@ -182,7 +182,7 @@ func TestHandleAgentSessionStart(t *testing.T) {

withStdin(t, `{"session_id":"spec-test"}`)
stdout := captureStdout(t, func() {
require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))
})

// Claude Code parses a hook's stdout as a single JSON value, so the two
Expand Down Expand Up @@ -216,7 +216,7 @@ func TestHandleAgentSessionStart(t *testing.T) {

withStdin(t, `{"session_id":"spec-done"}`)
stdout := captureStdout(t, func() {
require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))
})

var got map[string]any
Expand All @@ -230,12 +230,12 @@ func TestHandleAgentSessionStart(t *testing.T) {

t.Run("ignores malformed stdin", func(t *testing.T) {
withStdin(t, `not json`)
assert.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
assert.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))
})

t.Run("ignores empty session ID", func(t *testing.T) {
withStdin(t, `{"session_id":""}`)
assert.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
assert.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))
})
}

Expand Down Expand Up @@ -393,7 +393,7 @@ func TestHandleAgentSessionEnd(t *testing.T) {
}))

withStdin(t, `{"session_id":"abc-123","cwd":"/some/path"}`)
require.NoError(t, HandleAgentSessionStart(provider, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(provider, nil, zerolog.Nop()))

rec, err := store.LoadSessionRecord("abc-123")
require.NoError(t, err)
Expand Down Expand Up @@ -785,7 +785,7 @@ func TestHandleAgentClaudeCodeSession(t *testing.T) {
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.NoError(t, HandleAgentSessionStart(p, nil, 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
Expand Down Expand Up @@ -947,7 +947,7 @@ func TestHandleAgentCommandTool_OtherCheckout(t *testing.T) {
}

withStdin(t, fmt.Sprintf(`{"session_id":%q,"cwd":%q,"hook_event_name":"SessionStart","source":"startup"}`, sid, home))
require.NoError(t, HandleAgentSessionStart(p, zerolog.Nop()))
require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop()))

// 1. The agent writes a file in the other checkout, twice.
modal := filepath.Join(other, "modal.tsx")
Expand Down
Loading
Loading