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
71 changes: 65 additions & 6 deletions app/cli/internal/trace/claude/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,38 @@ func addHookEntry(hooks map[string]any, event, command, matcher string) bool {
if !entryContainsChainloopHook(entry) {
continue
}
// Chainloop entry exists — check if it needs updating (e.g., matcher changed)
// Chainloop entry exists — check if it needs updating (e.g., matcher
// changed, or an entry written before the missing-CLI guard)
m, _ := entry.(map[string]any)
existingMatcher, _ := m["matcher"].(string)
if existingMatcher == matcher {
guarded := trace.GuardHookCommand(command)
if existingMatcher == matcher && chainloopHookCommand(m) == guarded {
return false // already up to date
}
// Replace the entry with updated version
entries[i] = buildHookEntry(command, matcher)
// The group can also hold the user's own handlers: keep them.
handlers, _ := m["hooks"].([]any)
var others []any
for _, h := range handlers {
if !handlerIsChainloop(h) {
others = append(others, h)
}
}
switch {
case len(others) == 0:
entries[i] = buildHookEntry(command, matcher)
case existingMatcher == matcher:
// Only the command is out of date: update the Chainloop handler in place.
for _, h := range handlers {
if handlerIsChainloop(h) {
h.(map[string]any)["command"] = guarded
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}
default:
// The matcher changed: leave the user's handlers under their matcher
// and give the Chainloop handler a group of its own.
m["hooks"] = others
entries = append(entries, buildHookEntry(command, matcher))
}
hooks[event] = entries
return true
}
Expand All @@ -258,12 +282,31 @@ func addHookEntry(hooks map[string]any, event, command, matcher string) bool {
return true
}

// handlerIsChainloop reports whether a handler in a hook group runs the Chainloop CLI.
func handlerIsChainloop(h any) bool {
handler, _ := h.(map[string]any)
cmd, _ := handler["command"].(string)
return strings.Contains(cmd, hookMarker)
}

// chainloopHookCommand returns the command of the Chainloop handler in a hook group.
func chainloopHookCommand(entry map[string]any) string {
handlers, _ := entry["hooks"].([]any)
for _, h := range handlers {
handler, _ := h.(map[string]any)
if cmd, _ := handler["command"].(string); strings.Contains(cmd, hookMarker) {
return cmd
}
}
return ""
}

func buildHookEntry(command, matcher string) map[string]any {
entry := map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": command,
"command": trace.GuardHookCommand(command),
},
},
}
Expand All @@ -280,13 +323,29 @@ func removeHookEntry(hooks map[string]any, event string) bool {
}

var kept []any
changed := false
for _, entry := range entries {
if !entryContainsChainloopHook(entry) {
kept = append(kept, entry)
continue
}
changed = true
// Strip only the Chainloop handler: the group can also hold the user's own.
m, _ := entry.(map[string]any)
handlers, _ := m["hooks"].([]any)
var others []any
for _, h := range handlers {
if !handlerIsChainloop(h) {
others = append(others, h)
}
}
if len(others) > 0 {
m["hooks"] = others
kept = append(kept, m)
}
}

if len(kept) == len(entries) {
if !changed {
return false
}

Expand Down
155 changes: 154 additions & 1 deletion app/cli/internal/trace/claude/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -456,5 +460,154 @@ func assertHookCommand(t *testing.T, hooks map[string]any, event, expectedCmd st

hook := innerHooks[0].(map[string]any)
assert.Equal(t, "command", hook["type"])
assert.Equal(t, expectedCmd, hook["command"])
assert.Equal(t, trace.GuardHookCommand(expectedCmd), hook["command"])
}

// The hooks are committed to the repository, so they also run for anyone who
// opens it without the chainloop CLI installed (#3687).
func TestInstalledHooksWithoutCLI(t *testing.T) {
shell := shellOnlyPath(t)
root := t.TempDir()
require.NoError(t, New().InstallHooks(root))

for _, event := range hookEvents {
t.Run(event.event+"/"+event.command, func(t *testing.T) {
cmd := exec.CommandContext(t.Context(), shell.sh, "-c", trace.GuardHookCommand(event.command)) //nolint:gosec // run the installed hook to check its shell behaviour
cmd.Env = append(os.Environ(), "PATH="+shell.bin)
cmd.Stdin = strings.NewReader(`{"session_id":"test"}`)
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
assert.Empty(t, out)
})
}
assertInstalledCommandsGuarded(t, root)
}

func TestInstalledHookPreservesIOAndExitStatus(t *testing.T) {
shell := shellOnlyPath(t)
stub := "#!/bin/sh\nIFS= read -r payload\nprintf '%s\\n%s\\n' \"$*\" \"$payload\"\nexit \"$HOOK_TEST_EXIT_CODE\"\n"
require.NoError(t, os.WriteFile(filepath.Join(shell.bin, "chainloop"), []byte(stub), 0700)) //nolint:gosec // executable stub CLI in a temporary test directory

for _, code := range []string{"0", "37"} {
t.Run("exit_"+code, func(t *testing.T) {
cmd := exec.CommandContext(t.Context(), shell.sh, "-c", trace.GuardHookCommand("chainloop trace hook claude post-tool-use")) //nolint:gosec // run the installed hook against a local stub CLI
cmd.Env = append(os.Environ(), "PATH="+shell.bin, "HOOK_TEST_EXIT_CODE="+code)
cmd.Stdin = strings.NewReader("{\"session_id\":\"test\"}\n")
out, err := cmd.Output()
if code == "0" {
require.NoError(t, err)
} else {
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 37, exitErr.ExitCode())
}
assert.Equal(t, "trace hook claude post-tool-use"+"\n{\"session_id\":\"test\"}\n", string(out))
})
}
}

type shellPath struct{ sh, bin string }

// shellOnlyPath returns a PATH directory holding only sh, so chainloop is missing.
func shellOnlyPath(t *testing.T) shellPath {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("the guard is a POSIX sh command")
}
sh, err := exec.LookPath("sh")
require.NoError(t, err)
bin := t.TempDir()
require.NoError(t, os.Symlink(sh, filepath.Join(bin, "sh")))
return shellPath{sh: sh, bin: bin}
}

func assertInstalledCommandsGuarded(t *testing.T, root string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, settingsFile))
require.NoError(t, err)
assert.Equal(t, len(hookEvents), strings.Count(string(data), "command -v chainloop"))
}

func TestInstallUpgradeKeepsUserHandlersInTheSameGroup(t *testing.T) {
const userHandler = `{"type":"command","command":"echo user-hook"}`
unguarded := `{"type":"command","command":"chainloop trace hook claude post-tool-use"}`

t.Run("same matcher", func(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, settingsFile)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
legacy := `{"hooks":{"PostToolUse":[{"matcher":"` + postHookToolMatcher + `","hooks":[` + userHandler + `,` + unguarded + `]}]}}`
require.NoError(t, os.WriteFile(path, []byte(legacy), 0600))

require.NoError(t, New().InstallHooks(root))
postToolUse := readSettings(t, root)["hooks"].(map[string]any)[eventPostToolUse].([]any)
require.Len(t, postToolUse, 1)
group := postToolUse[0].(map[string]any)
assert.Equal(t, postHookToolMatcher, group["matcher"])
handlers := group["hooks"].([]any)
require.Len(t, handlers, 2)
assert.Equal(t, "echo user-hook", handlers[0].(map[string]any)["command"])
assert.Equal(t, trace.GuardHookCommand("chainloop trace hook claude post-tool-use"), handlers[1].(map[string]any)["command"])
})

t.Run("changed matcher", func(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, settingsFile)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
legacy := `{"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[` + userHandler + `,` + unguarded + `]}]}}`
require.NoError(t, os.WriteFile(path, []byte(legacy), 0600))

require.NoError(t, New().InstallHooks(root))
postToolUse := readSettings(t, root)["hooks"].(map[string]any)[eventPostToolUse].([]any)
require.Len(t, postToolUse, 2)
user := postToolUse[0].(map[string]any)
assert.Equal(t, "Bash", user["matcher"], "the user's handler keeps its matcher")
assert.Equal(t, []any{map[string]any{"type": "command", "command": "echo user-hook"}}, user["hooks"])
chainloop := postToolUse[1].(map[string]any)
assert.Equal(t, postHookToolMatcher, chainloop["matcher"])
assert.Equal(t, trace.GuardHookCommand("chainloop trace hook claude post-tool-use"), chainloopHookCommand(chainloop))
})
}

func TestUninstallKeepsUserHandlersInAChainloopGroup(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, settingsFile)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
shared := `{"hooks":{"PostToolUse":[{"matcher":"` + postHookToolMatcher + `","hooks":[{"type":"command","command":"echo user-hook"},{"type":"command","command":"` +
strings.ReplaceAll(trace.GuardHookCommand("chainloop trace hook claude post-tool-use"), `"`, `\"`) + `"}]}]}}`
require.NoError(t, os.WriteFile(path, []byte(shared), 0600))

require.NoError(t, New().UninstallHooks(root))
postToolUse := readSettings(t, root)["hooks"].(map[string]any)[eventPostToolUse].([]any)
require.Len(t, postToolUse, 1)
group := postToolUse[0].(map[string]any)
assert.Equal(t, postHookToolMatcher, group["matcher"])
assert.Equal(t, []any{map[string]any{"type": "command", "command": "echo user-hook"}}, group["hooks"])
}

func TestInstallUpgradesUnguardedHooks(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, settingsFile)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
legacy := `{"hooks":{"PostToolUse":[{"matcher":"` + postHookToolMatcher + `","hooks":[{"type":"command","command":"chainloop trace hook claude post-tool-use"}]},{"hooks":[{"type":"command","command":"echo user-hook"}]}]}}`
require.NoError(t, os.WriteFile(path, []byte(legacy), 0600))

require.NoError(t, New().InstallHooks(root))
first, err := os.ReadFile(path)
require.NoError(t, err)
var settings struct {
Hooks map[string][]map[string]any `json:"hooks"`
}
require.NoError(t, json.Unmarshal(first, &settings))
postToolUse := settings.Hooks[eventPostToolUse]
require.Len(t, postToolUse, 2, "the chainloop entry is replaced, not duplicated")
assert.Equal(t, trace.GuardHookCommand("chainloop trace hook claude post-tool-use"), chainloopHookCommand(postToolUse[0]))
assert.NotContains(t, string(first), `"command": "chainloop trace hook claude post-tool-use"`)
assert.Contains(t, string(first), "echo user-hook")

// Idempotent once upgraded.
require.NoError(t, New().InstallHooks(root))
second, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, string(first), string(second))
}
17 changes: 11 additions & 6 deletions app/cli/internal/trace/codex/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ import (
const (
hookMarker = "chainloop trace hook codex"
settingsFile = ".codex/hooks.json"
// Skip tracing when the CLI is missing, before any Go handler can run.
hookCommandPrefix = "sh -c 'if ! command -v chainloop >/dev/null 2>&1; then exit 0; fi; exec "
// Skip tracing on Windows when the CLI is missing, before any Go handler
// can run. The POSIX command uses trace.GuardHookCommand, like the other
// providers.
windowsHookPrefix = `cmd.exe /d /s /c "where chainloop >nul 2>nul & if errorlevel 1 (exit /b 0) else (`

// Codex hook event names.
Expand Down Expand Up @@ -254,7 +255,7 @@ func addHookEntry(hooks map[string]any, event, command, matcher, statusMessage s
existingMatcher, _ := group["matcher"].(string)
if len(handlers) == 1 && existingMatcher == matcher {
handler, _ := handlers[0].(map[string]any)
if handler["command"] == hookCommandPrefix+command+"'" && handler["commandWindows"] == windowsHookPrefix+command+`)"` && handler["statusMessage"] == statusMessage {
if handler["command"] == trace.GuardHookCommand(command) && handler["commandWindows"] == windowsHookPrefix+command+`)"` && handler["statusMessage"] == statusMessage {
return false
}
}
Expand All @@ -271,7 +272,7 @@ func buildHookEntry(command, matcher, statusMessage string) map[string]any {
map[string]any{
"type": "command",
"statusMessage": statusMessage,
"command": hookCommandPrefix + command + "'",
"command": trace.GuardHookCommand(command),
"commandWindows": windowsHookPrefix + command + `)"`,
},
},
Expand Down Expand Up @@ -319,14 +320,18 @@ func removeHookEntry(hooks map[string]any, event string) bool {
return true
}

// guardPrefix is the part of a guarded command that comes before the wrapped
// command; trace.GuardHookCommand closes it with a single quote.
var guardPrefix = strings.TrimSuffix(trace.GuardHookCommand(""), "'")

func isChainloopHandler(handler any) bool {
h, ok := handler.(map[string]any)
if !ok {
return false
}
command, _ := h["command"].(string)
if strings.HasPrefix(command, hookCommandPrefix) && strings.HasSuffix(command, "'") {
command = strings.TrimSuffix(strings.TrimPrefix(command, hookCommandPrefix), "'")
if strings.HasPrefix(command, guardPrefix) && strings.HasSuffix(command, "'") {
command = strings.TrimSuffix(strings.TrimPrefix(command, guardPrefix), "'")
}
return strings.HasPrefix(command, hookMarker+" ")
}
Expand Down
19 changes: 16 additions & 3 deletions app/cli/internal/trace/cursor/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,27 @@ type cursorHookEvent struct {
// matcher limits a tool hook to the tools whose name it matches. Empty
// for hooks that are not tool hooks.
matcher string
// missingCLIOutput is what the hook prints when the chainloop CLI is not
// installed. Empty for hooks whose output Cursor does not require.
missingCLIOutput string
}

// guardedCommand is the hook command as installed, wrapped in the missing-CLI guard.
func (h cursorHookEvent) guardedCommand() string {
if h.missingCLIOutput != "" {
return trace.GuardHookCommandWithOutput(h.command, h.missingCLIOutput)
}
return trace.GuardHookCommand(h.command)
}

// cursorHookEvents is the full list of hooks installed by InstallHooks.
var cursorHookEvents = []cursorHookEvent{
{event: eventSessionStart, command: "chainloop trace hook cursor session-start"},
{event: eventSessionEnd, command: "chainloop trace hook cursor session-end"},
{event: eventAfterFileEdit, command: "chainloop trace hook cursor after-file-edit"},
{event: eventPreToolUse, command: "chainloop trace hook cursor pre-tool-use", matcher: shellToolName},
// Cursor blocks the shell command when preToolUse writes no valid response,
// so the guard answers for a missing CLI the way AllowToolUse does.
{event: eventPreToolUse, command: "chainloop trace hook cursor pre-tool-use", matcher: shellToolName, missingCLIOutput: allowToolUseResponse},
{event: eventPostToolUse, command: "chainloop trace hook cursor post-tool-use", matcher: shellToolName},
}

Expand Down Expand Up @@ -289,7 +302,7 @@ func addHookEntry(hooks map[string]any, h cursorHookEvent) bool {
m, _ := entry.(map[string]any)
existingCmd, _ := m["command"].(string)
existingMatcher, _ := m["matcher"].(string)
if existingCmd == h.command && existingMatcher == h.matcher {
if existingCmd == h.guardedCommand() && existingMatcher == h.matcher {
return false
}
entries[i] = buildHookEntry(h)
Expand Down Expand Up @@ -331,7 +344,7 @@ func removeHookEntry(hooks map[string]any, event string) bool {

func buildHookEntry(h cursorHookEvent) map[string]any {
entry := map[string]any{
"command": h.command,
"command": h.guardedCommand(),
"timeout": defaultHookTimeoutSeconds,
}
if h.matcher != "" {
Expand Down
Loading
Loading