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
66 changes: 66 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"command": "sh -c 'if ! command -v chainloop >/dev/null 2>&1; then exit 0; fi; exec chainloop trace hook codex post-tool-use'",
"commandWindows": "cmd.exe /d /s /c \"where chainloop >nul 2>nul & if errorlevel 1 (exit /b 0) else (chainloop trace hook codex post-tool-use)\"",
"type": "command",
"statusMessage": "Chainloop Trace: record tool changes"
}
],
"matcher": "^(apply_patch|Bash)$"
}
],
"PreToolUse": [
{
"hooks": [
{
"command": "sh -c 'if ! command -v chainloop >/dev/null 2>&1; then exit 0; fi; exec chainloop trace hook codex pre-tool-use'",
"commandWindows": "cmd.exe /d /s /c \"where chainloop >nul 2>nul & if errorlevel 1 (exit /b 0) else (chainloop trace hook codex pre-tool-use)\"",
"type": "command",
"statusMessage": "Chainloop Trace: snapshot files before changes"
}
],
"matcher": "^(apply_patch|Bash)$"
}
],
"SessionEnd": [
{
"hooks": [
{
"command": "sh -c 'if ! command -v chainloop >/dev/null 2>&1; then exit 0; fi; exec chainloop trace hook codex session-end'",
"commandWindows": "cmd.exe /d /s /c \"where chainloop >nul 2>nul & if errorlevel 1 (exit /b 0) else (chainloop trace hook codex session-end)\"",
"type": "command",
"statusMessage": "Chainloop Trace: finish session"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"command": "sh -c 'if ! command -v chainloop >/dev/null 2>&1; then exit 0; fi; exec chainloop trace hook codex session-start'",
"commandWindows": "cmd.exe /d /s /c \"where chainloop >nul 2>nul & if errorlevel 1 (exit /b 0) else (chainloop trace hook codex session-start)\"",
"type": "command",
"statusMessage": "Chainloop Trace: start session"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"command": "sh -c 'if ! command -v chainloop >/dev/null 2>&1; then exit 0; fi; exec chainloop trace hook codex user-prompt-submit'",
"commandWindows": "cmd.exe /d /s /c \"where chainloop >nul 2>nul & if errorlevel 1 (exit /b 0) else (chainloop trace hook codex user-prompt-submit)\"",
"type": "command",
"statusMessage": "Chainloop Trace: capture prompt context"
}
]
}
]
}
}
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ chainloop trace init # installs the git and agent hooks, writes .chainloo

`trace init` writes `.chainloop.yml` and git hooks (`pre-push`, `post-commit`, `commit-msg`, `post-rewrite`) in the current repo, and `trace uninstall` removes them. Session data stays on your machine until `git push`, and the CLI redacts secrets before upload. The [AI Sessions Quickstart](https://docs.chainloop.dev/ai-sessions?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=try-it-in-60-seconds) lists what it sends.

Now code with Claude Code, OpenCode, or Cursor, commit, and `git push`. The push hook attests the session and prints where to see it:
For Codex, run `chainloop trace init --codex`. Review and trust the installed hooks in Codex before starting work. See the [agent support matrix](./app/cli/internal/trace/README.md) for verified features and limits.

Now code with Claude Code, OpenCode, Codex, or Cursor, commit, and `git push`. The push hook attests the session and prints where to see it:

```text
Coding Session Available at https://app.chainloop.dev/u/<org>/sessions/<id>
Expand Down Expand Up @@ -184,7 +186,7 @@ A workflow contract is the API between the people who set the rules and the peop

| Component | What it does |
|---|---|
| [AI session collector](./app/cli/cmd/trace.go) (`chainloop trace`) | Records Claude Code, OpenCode, and Cursor sessions, redacts secrets, attributes AI and human lines, and attests the session on `git push`. `requireTrace` in `.chainloop.yml` blocks a push that has no record. |
| [AI session collector](./app/cli/cmd/trace.go) (`chainloop trace`) | Records Claude Code, OpenCode, Codex, and Cursor sessions, redacts secrets, attributes AI and human lines, and attests the session on `git push`. `requireTrace` in `.chainloop.yml` blocks a push that has no record. |
| [CLI](./app/cli) (`chainloop`) | One integration point for every major CI runner and 45 evidence types. It crafts, signs, and pushes attestations, and runs policies. |
| [Trusted store](./app/artifact-cas) | Content-addressable storage for evidence, backed by an OCI registry, S3, Azure Blob Storage, or inline. Your storage, your keys. Called the Artifact CAS in the code. |
| [Signing](./pkg/attestation/signer) | in-toto attestations, keyless by default, or your own cosign / KMS keys, or SignServer. Timestamped. |
Expand Down Expand Up @@ -231,7 +233,7 @@ During an attestation you can attach these evidence types. The CLI uploads files

## Use cases

**Put guardrails on coding agents.** Record every Claude Code, OpenCode, or Cursor session as a signed record, and run your policies on every session. Catch an unapproved model, a secret in the transcript, or more AI lines than the rule allows. Chainloop signs violations into the record, and they fail the pipeline that builds the PR. `requireTrace: true` rejects a push with no recorded session. → [AI Sessions Quickstart](https://docs.chainloop.dev/ai-sessions?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=use-cases) · [Policies](https://docs.chainloop.dev/concepts/policies?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=use-cases) · [AI coding governance](https://chainloop.dev/solutions/ai-coding-governance/?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=use-cases)
**Put guardrails on coding agents.** Record every Claude Code, OpenCode, Codex, or Cursor session as a signed record, and run your policies on every session. Catch an unapproved model, a secret in the transcript, or more AI lines than the rule allows. Chainloop signs violations into the record, and they fail the pipeline that builds the PR. `requireTrace: true` rejects a push with no recorded session. → [AI Sessions Quickstart](https://docs.chainloop.dev/ai-sessions?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=use-cases) · [Policies](https://docs.chainloop.dev/concepts/policies?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=use-cases) · [AI coding governance](https://chainloop.dev/solutions/ai-coding-governance/?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=use-cases)

**Trace a release back to the prompt.** Follow any release back through the build and the commit to the AI session that started it. `chainloop referrer discover --digest <sha256>` returns every attestation that references an artifact, SBOM, or commit, and what those reference in turn. One record, from prompt to production. → [AI Sessions Quickstart](https://docs.chainloop.dev/ai-sessions?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=use-cases)

Expand Down Expand Up @@ -259,7 +261,8 @@ Git tells you what changed. It no longer tells you how. `chainloop trace` record
| Claude Code | Supported, with token usage and cost |
| OpenCode | Supported, with token usage and cost |
| Cursor | Experimental, no token usage or cost yet |
| Codex, GitHub Copilot, Gemini, Windsurf, Amp | Coming. [Contributions welcome](#community-and-contributing) |
| Codex | Supported, with token usage and estimated cost |
| GitHub Copilot, Gemini, Windsurf, Amp | Coming. [Contributions welcome](#community-and-contributing) |

Wanted: new collector providers for the agents above, and new evidence types. The provider interface is in [app/cli/internal/trace](./app/cli/internal/trace).

Expand Down Expand Up @@ -351,7 +354,7 @@ You can also download the CLI from the [releases page](https://github.com/chainl
## For AI coding agents

> [!NOTE]
> **If you are an AI coding agent setting up Chainloop in this repository:** Install the CLI with `curl -sfL https://dl.chainloop.dev/cli/install.sh | bash -s -- --oss`. Ask the user to run `chainloop auth login`, which opens a browser. Then run `chainloop trace init --project <name>` from the repository root. Add `--claude`, `--cursor`, or `--opencode` for the agents in use. The command writes `.chainloop.yml` and git hooks. Commit `.chainloop.yml`. The docs index for agents is https://docs.chainloop.dev/llms.txt. Any docs page is available as Markdown when you add `.md` to its URL. [Documentation](https://docs.chainloop.dev/ai-sessions?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=for-ai-coding-agents)
> **If you are an AI coding agent setting up Chainloop in this repository:** Install the CLI with `curl -sfL https://dl.chainloop.dev/cli/install.sh | bash -s -- --oss`. Ask the user to run `chainloop auth login`, which opens a browser. Then run `chainloop trace init --project <name>` from the repository root. Add `--claude`, `--cursor`, `--opencode`, or `--codex` for the agents in use. The command writes `.chainloop.yml` and Git hooks, plus `.codex/hooks.json` when using Codex. Commit `.chainloop.yml` and the generated `.codex/hooks.json`. Each clone must still run `chainloop trace init` to install its local Git hooks. The docs index for agents is https://docs.chainloop.dev/llms.txt. Any docs page is available as Markdown when you add `.md` to its URL. [Documentation](https://docs.chainloop.dev/ai-sessions?utm_source=github&utm_medium=readme&utm_campaign=chainloop-oss&utm_content=for-ai-coding-agents)

Or paste this into Claude Code, Codex, or Cursor:

Expand Down
20 changes: 20 additions & 0 deletions app/cli/cmd/trace_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/codex"
"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"
Expand All @@ -40,6 +41,7 @@ func newTraceHookCmd() *cobra.Command {
newTraceHookClaudeCmd(),
newTraceHookCursorCmd(),
newTraceHookOpenCodeCmd(),
newTraceHookCodexCmd(),
)

return cmd
Expand Down Expand Up @@ -451,3 +453,21 @@ func newTraceHookOpenCodePostToolUseCmd() *cobra.Command {
},
}
}

func newTraceHookCodexCmd() *cobra.Command {
cmd := &cobra.Command{Use: "codex", Short: "Handle Codex hook invocations"}
handlers := []struct {
name, description string
run func(*cobra.Command, []string) error
}{
{"session-start", "Capture the Codex session", func(cmd *cobra.Command, _ []string) error { return runAgentSessionStart(cmd, codex.New()) }},
{"user-prompt-submit", "Remind Codex to capture specs", func(_ *cobra.Command, _ []string) error { return action.HandleAgentPromptSubmit(codex.New(), logger) }},
{"pre-tool-use", "Snapshot files before a Codex tool", func(_ *cobra.Command, _ []string) error { return action.HandleAgentPreToolUse(codex.New(), logger) }},
{"post-tool-use", "Record changes after a Codex tool", func(_ *cobra.Command, _ []string) error { return action.HandleAgentPostToolUse(codex.New(), logger) }},
{"session-end", "Copy the completed Codex session", func(_ *cobra.Command, _ []string) error { return action.HandleAgentSessionEnd(codex.New(), logger) }},
}
for _, h := range handlers {
cmd.AddCommand(&cobra.Command{Use: h.name, Short: h.description, Args: cobra.NoArgs, Annotations: map[string]string{skipActionOptsInit: trueString}, RunE: func(cmd *cobra.Command, args []string) error { InitHookLogger(); return h.run(cmd, args) }})
}
return cmd
}
17 changes: 15 additions & 2 deletions app/cli/cmd/trace_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (

"github.com/chainloop-dev/chainloop/app/cli/internal/repositoryconfig"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/codex"
"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"
Expand All @@ -47,6 +48,7 @@ func newTraceInitCmd() *cobra.Command {
claudeFlag bool
cursorFlag bool
opencodeFlag bool
codexFlag bool
)

cmd := &cobra.Command{
Expand All @@ -72,7 +74,7 @@ named freely; the name is normalized to the lowercase, dash-separated form
Chainloop stores. It then asks which harnesses to trace, with the ones the
repository is already set up for ticked, or Claude Code on a first run; use
space to tick more. Passing --org, --project or a harness flag
(--claude, --cursor, --opencode) skips the matching question.
(--claude, --cursor, --opencode, --codex) skips the matching question.

Nothing is asked in CI or when the output is redirected: there --project is
required unless .chainloop.yml carries projectName. Set CHAINLOOP_NO_PROMPT to
Expand Down Expand Up @@ -155,7 +157,7 @@ cannot use them with --local. You can use --require-trace with --local.`,
// so every question is answered up front and an abort leaves the
// repository untouched.
selected, err := resolveTraceProviders(newNamingPrompter(os.LookupEnv),
traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag},
traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag, codex: codexFlag},
traceInitCanPrompt(), func() []string { return configuredTraceProviders(repoRoot) })
if err != nil {
return stopIfAborted(err)
Expand Down Expand Up @@ -293,6 +295,7 @@ cannot use them with --local. You can use --require-trace with --local.`,
cmd.Flags().Bool("require-trace", false, "block pushes when a required trace is missing")
cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no harness flag is set)")
cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks")
cmd.Flags().BoolVar(&codexFlag, "codex", false, "install Codex hooks")
cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks")

return cmd
Expand Down Expand Up @@ -380,6 +383,11 @@ What's next

Learn more: %s
`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL)
for _, p := range installed {
if p.Name() == codex.Name {
fmt.Fprintln(w, "For Codex, review and trust the installed hooks in Codex before starting work.")
}
}
}

// writeTraceInitLocalSummary reports what a local init set up. It names no
Expand Down Expand Up @@ -437,6 +445,11 @@ What's next

Learn more: %s
`, strings.Join(files, " "), joinWithOr(providerNames(installed)), traceDocsURL)
for _, p := range installed {
if p.Name() == codex.Name {
fmt.Fprintln(w, "For Codex, review and trust the installed hooks in Codex before starting work.")
}
}
}

// providerNames names the harnesses, in the order they were installed.
Expand Down
13 changes: 8 additions & 5 deletions app/cli/cmd/trace_init_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,28 @@ 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/codex"
"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/internal/trace/providers"
)

const providersPromptTitle = "Harnesses to trace in this repository"

// traceProviderFlags records which --claude / --cursor / --opencode flags the
// user set.
// traceProviderFlags records the harness flags that the user set.
type traceProviderFlags struct {
claude, cursor, opencode bool
claude, cursor, opencode, codex bool
}

// any reports whether the user named at least one provider.
func (f traceProviderFlags) any() bool {
return f.claude || f.cursor || f.opencode
return f.claude || f.cursor || f.opencode || f.codex
}

// names returns the providers the flags select, in the registry's order rather
// than the order the flags happen to appear in.
func (f traceProviderFlags) names() []string {
out := make([]string, 0, 3)
out := make([]string, 0, 4)
if f.claude {
out = append(out, claude.Name)
}
Expand All @@ -52,6 +52,9 @@ func (f traceProviderFlags) names() []string {
out = append(out, opencode.Name)
}

if f.codex {
out = append(out, codex.Name)
}
return out
}

Expand Down
5 changes: 5 additions & 0 deletions app/cli/cmd/trace_init_providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ func TestConfiguredTraceProvidersSkipsUnreadableSettings(t *testing.T) {
}

func TestTraceProviderFlags(t *testing.T) {
t.Run("codex flag", func(t *testing.T) {
f := traceProviderFlags{codex: true}
assert.True(t, f.any())
assert.Equal(t, []string{"codex"}, f.namesOrDefault())
})
t.Run("no flag set", func(t *testing.T) {
assert.False(t, traceProviderFlags{}.any())
assert.Empty(t, traceProviderFlags{}.names())
Expand Down
7 changes: 4 additions & 3 deletions app/cli/cmd/trace_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"slices"
"testing"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace/opencode"
"github.com/chainloop-dev/chainloop/app/cli/internal/trace/providers"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -326,14 +327,14 @@ func TestWriteTraceNextSteps(t *testing.T) {
},
{
name: "every harness",
providers: []string{providerClaudeCode, "cursor", "opencode"},
providers: []string{providerClaudeCode, "cursor", opencode.Name, "codex"},
// Named in full: ".opencode" alone would pass on any file under that
// directory, so it would not catch the plugin being renamed.
wantFiles: []string{
chainloopYMLName, claudeSettings, ".cursor/hooks.json",
".opencode/plugins/chainloop-trace.ts",
".opencode/plugins/chainloop-trace.ts", ".codex/hooks.json",
},
wantHarnesses: "claude-code, cursor or opencode",
wantHarnesses: "claude-code, cursor, opencode or codex",
},
}

Expand Down
4 changes: 3 additions & 1 deletion app/cli/cmd/trace_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func newTraceRunCmd() *cobra.Command {
claudeFlag bool
cursorFlag bool
opencodeFlag bool
codexFlag bool
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -165,7 +166,7 @@ func newTraceRunCmd() *cobra.Command {
return action.TraceRun(cmd.Context(), logger, action.TraceRunOpts{
Store: store,
RepoRoot: repoRoot,
Providers: traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag}.namesOrDefault(),
Providers: traceProviderFlags{claude: claudeFlag, cursor: cursorFlag, opencode: opencodeFlag, codex: codexFlag}.namesOrDefault(),
Command: args,
ProjectName: projectFlag,
Organization: organization,
Expand All @@ -187,6 +188,7 @@ func newTraceRunCmd() *cobra.Command {
cmd.Flags().BoolVar(&localFlag, "local", false, "store the session on disk, with no control plane, login or workflow")
cmd.Flags().BoolVar(&claudeFlag, "claude", false, "install Claude Code hooks (default when no provider flag is set)")
cmd.Flags().BoolVar(&cursorFlag, "cursor", false, "install Cursor hooks")
cmd.Flags().BoolVar(&codexFlag, "codex", false, "install Codex hooks")
cmd.Flags().BoolVar(&opencodeFlag, "opencode", false, "install opencode hooks")

// project and workflow are not required in local mode, so the check is done
Expand Down
Loading
Loading