Skip to content

fix(trace): attribute commits made inside Bash calls - #3631

Open
waveywaves wants to merge 3 commits into
chainloop-dev:mainfrom
waveywaves:fix/trace-bash-commit-attribution
Open

waveywaves wants to merge 3 commits into
chainloop-dev:mainfrom
waveywaves:fix/trace-bash-commit-attribution

Conversation

@waveywaves

@waveywaves waveywaves commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #3625

Claude Code can edit, stage, commit, and push within one Bash tool call. The commit-msg hook runs before Claude's PostToolUse hook, so the staged files previously had no recorded AI attribution and the commit lost its Chainloop-Trace-Sessions trailer.

This change:

  • tags each Claude Bash call with an opaque call token while preserving the rest of tool_input
  • snapshots both the worktree and staged index before the command runs
  • lets commit-msg attribute the exact staged blob changes from that call before PostToolUse
  • checkpoints only committed paths, preserving real unstaged changes and avoiding duplicate attribution from Git filters or line-ending normalization
  • keeps linked-checkout handling and session cleanup scoped to the matching shell snapshots

Testing

  • go test -race -shuffle=on -count=1 ./internal/trace/state ./internal/trace/claude ./internal/trace/git ./pkg/action
  • GOTOOLCHAIN=go1.27.2 go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 run --new-from-rev=upstream/main
  • go build ./...
  • exercised the commit-msg CLI help and invalid-argument paths
  • exercised same-call commit attribution, clean-filter normalization, and real unstaged CRLF preservation

AI disclosure: this change was made with Vibhav's Pi.

View guided diff

@chainloop-platform

chainloop-platform Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

AI Session Checks — ⚠️ no AI session found

Missing AI Coding Sessions

This organization requires every PR to be backed by a Chainloop Trace AI coding session, and none was found for this one.

Please make sure the AI coding session evidence has been sent by the Chainloop CLI, or add the skip-ai-session label to this PR to bypass this check.

Learn more about Chainloop Trace.

Security Checks — evaluating

Policies are still being evaluated. Results will update once available.

Scans not applied (3)
Scan Reason
vulnerability-scan no manifest/lockfile changed
github-actions-scan no workflow files changed
iac-scan no IaC files changed

Security context

This change touches code with 4 recorded security-fix advisories. These are pointers to what past fixes established, not findings in this diff, and they never fail the check.

View in Chainloop ↗ · How this works ↗


Powered by Chainloop and Chainloop Trace

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 13 files

Heads up: you’ve reached your flex budget. Increase your flex budget or wait for usage to reset.

View guided diff | Re-trigger cubic

Comment thread app/cli/pkg/action/trace_hook_handler.go
Comment thread app/cli/pkg/action/trace_bash_commit.go Outdated
Comment thread app/cli/internal/trace/git/index.go Outdated
@waveywaves
waveywaves force-pushed the fix/trace-bash-commit-attribution branch from d65514e to 26f4d6d Compare October 15, 2026 20:09
@migmartri
migmartri requested a review from a team October 15, 2026 20:13
@migmartri

Copy link
Copy Markdown
Member

@jiparis can you take a look at this one?

Tag the exact Claude Bash call so commit-msg can attribute staged content before PostToolUse runs.

Assisted-by: Vibhav's Pi
Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
@waveywaves
waveywaves force-pushed the fix/trace-bash-commit-attribution branch from 26f4d6d to 0b7e872 Compare October 15, 2026 20:14
Honor the effective Git index, preserve recreated paths after staged deletions, and distinguish conflict-stage signatures.

Assisted-by: Vibhav's Pi
Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Heads up: you’ve reached your flex budget. Increase your flex budget or wait for usage to reset.

View guided diff | Re-trigger cubic

Comment thread app/cli/internal/trace/git/index.go Outdated
@jiparis
jiparis self-requested a review October 15, 2026 22:20
@jiparis

jiparis commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Thanks for this fix. I tested the branch in real headless Claude Code sessions (Claude Code 2.1.301, chainloop trace init --local --claude, a bare remote). I compared it with its base commit 580a8015. The test command is the one from #3625, in one Bash call:

echo "change" >> README.md && git add README.md && git commit -m "same call commit" && git push origin HEAD:main

Results

Scenario Base This PR
All Bash calls allowed Commit has no trailer and no session evidence (bug reproduced) Commit has Chainloop-Trace-Sessions, and the pre-push hook writes the session evidence. Fixed.
Narrow allowlist Bash(git status:*), command git status --short Runs Denied: This Bash command contains multiple operations. The following part requires approval: export CHAINLOOP_TRACE_SHELL_CALL=eyJ...
A second PreToolUse hook also returns updatedInput, and runs after the Chainloop hook n/a Command runs. The trailer is lost, with no warning.
The same second hook runs before the Chainloop hook n/a Trailer is present

Scenarios and suggested approach

1. Permission check on the rewritten command (blocker)

Claude Code applies updatedInput without permissionDecision. Then it does the permission check on the rewritten command. The export ... part matches no allow rule. Thus every Bash call in a traced repository needs approval, also read-only calls such as git status. The only exception is a user who allows all Bash calls or bypasses permissions. In headless runs and in CI, Claude Code denies the call. In interactive sessions, we expect a permission prompt on each Bash call that shows the token. We did not test the interactive case.

Suggested approach: do not rewrite the command. Use the snapshot files that this PR already writes (shell-pre-call-<id>.json and shell-index-call-<id>.json) as the marker of an open Bash call. Identify the session from the environment. Claude Code already exports CLAUDE_CODE_SESSION_ID to its Bash subprocesses. We checked that it is the same value as the hook session_id, also in a subagent. In commit-msg:

  1. Read CLAUDE_CODE_SESSION_ID. If it is not set, the commit does not come from a Claude Code shell, so do not do the checkpoint.
  2. Load the open shell snapshots of that session only.
  3. For each snapshot, compare the index before the call with the index now. Keep the changed paths that are also staged.
  4. Checkpoint the committed paths, as this PR does now.

There is no updatedInput rewrite. Thus the conflict with other hooks (scenario 2) does not occur. The same approach can also work for other providers that have a pre-tool hook.

Notes:

  • CLAUDE_CODE_SESSION_ID is not documented, as far as we know, and it can change. Add a fallback for when it is not set (see scenario 5).
  • The environment does not give the tool_use_id. Thus we can find the session, but not the exact call. The trailer needs only the session ID, so this is sufficient.

2. Another hook that also returns updatedInput

When two PreToolUse hooks return updatedInput, the hook that runs last wins. If the other hook runs after the Chainloop hook, the token is lost. The original bug then occurs again, and the user does not see a message.

Suggested approach: the approach in scenario 1 prevents this problem, because it does not rewrite the command.

3. Parallel Bash calls and subagents in the same session

We logged the hook events of one session:

  • When the model sends two Bash calls in one message, the calls overlap. They have the same session_id and no agent_id.
  • Subagents use the session_id of the parent session, and each subagent has its own agent_id. When they run in parallel, their Bash calls overlap.

This PR is not affected, because each call has its own token. For the approach in scenario 1, one session can have more than one open snapshot.

Suggested approach: compare the index with each open snapshot of the session, and use the union of the changed paths. All the calls are in the same session, so the trailer is correct. The worst result is less exact line ranges in that session.

4. Background Bash calls (run_in_background: true)

PostToolUse runs approximately 30 ms after PreToolUse, while the command continues to run. PostToolUse deletes the snapshot. A commit that the background command makes later finds no snapshot, also with the token of this PR. This gap is also present on the base commit.

Suggested approach: handle this case separately. One option is to keep the snapshot of a background call until the next user prompt, or until a maximum age. If we do not fix it now, document it as a known limit.

5. Commits from a terminal when no Claude Code environment is available

If commit-msg uses only the snapshot files, it can give credit to the AI session for a human commit. This occurs when a person commits from a terminal while a Claude Bash call is open (for example, a long npm test or a dev server). A snapshot that stays open after a crash (PostToolUse does not run) causes the same problem for each subsequent commit.

Suggested approach:

  • Use the snapshot-only path only when CLAUDE_CODE_SESSION_ID is not set and only one session has open snapshots in that checkout. In other cases, do not attribute the commit, and log a warning.
  • Ignore snapshots that are older than a maximum age, or that belong to a session that is not active.

6. More than one session in the same repository

  • Separate checkouts (linked worktrees): each checkout has its own index, and the key of each snapshot includes the root. The sessions do not affect each other. A cd ../other-checkout && git commit also works, because shellSnapshotRoots includes the checkouts that the session registered.

  • Same checkout, sessions that do not overlap: the filter on CLAUDE_CODE_SESSION_ID keeps the snapshots of other sessions out. Changes that the Edit and Write tools made are already in the AI line attributions, so matchSessionsToFiles adds those sessions as it does now.

  • Same checkout, two sessions with open Bash calls at the same time: this is an edge case.

    1. Session A starts a Bash call, and the snapshot records the index.
    2. Session B runs git add foo.go in its own Bash call.
    3. Session A runs git commit. Git commits the full index, and the diff of session A includes foo.go.
    4. The commit gives credit for foo.go to session A only. Later, the PostToolUse of session B records ranges for foo.go as pending, but the file is already in a commit. These ranges can then attach to a later commit that changes foo.go and is not related.

    The cause is the shared git index, not this design.

    Suggested approach: in commit-msg, also examine the open snapshots of other sessions in the same checkout. For staged paths that the diff of the current session does not explain, add the sessions whose diff includes them. The trailer then gives credit to more sessions than necessary, but it does not lose a session. Alternatively, record a warning on the commit when more than one session has open snapshots in the same checkout.

Recommendation: minimal version for this PR

We recommend that this PR uses only the core of scenario 1. Keep the extras from scenarios 4, 5 and 6 for later changes, when we have evidence that they are necessary.

Minimal version:

  1. commit-msg reads CLAUDE_CODE_SESSION_ID. If it is not set, commit-msg does not do the checkpoint. This is the same behavior as on the base commit, thus there is no regression.
  2. commit-msg loads all the open shell snapshots of that session, and does the checkpoint for each snapshot.
  3. Document the background Bash gap (scenario 4) as a known limit.

Comparison with the current PR:

Current PR Minimal version
Bash calls with a narrow allowlist Denied, or a prompt on each call No change in permissions
Another hook that rewrites the command Trailer lost, with no warning No conflict
Token in the transcript and in prompts Yes, on each Bash call No
Attribution precision Exact call Session. This is sufficient for the trailer. Line ranges can be less exact when calls overlap in one session.
Dependency updatedInput (documented) CLAUDE_CODE_SESSION_ID (not documented, as far as we know). If Claude Code stops setting it, the behavior is the same as on the base commit.
Background Bash calls Not attributed Not attributed

Estimated size of the change:

Change Size
Delete TagShellCommand, ShellCommandTagger, RawToolInput, the ReadHookInput changes, EncodeShellCallKey/DecodeShellCallKey, and the ShellCallEnv export Approximately −100 lines
Add store.OpenShellSnapshotKeys(sessionID). It lists the shell-index-* files in snapshots/<session>/ and makes each key from the file name. Approximately +30 lines
In checkpointActiveShellAttribution, read the environment variable instead of the token, and do the checkpoint for each key. The diff and checkpoint logic does not change. Approximately +15 lines
Tests: set CLAUDE_CODE_SESSION_ID instead of the token. Add one test with two overlapping calls in the same session. Small

The snapshot, index, checkpoint and cleanup logic of this PR stays. Thus we expect the result to be smaller than the current PR.

Other items

  • The Chainloop AI Policies and Chainloop PR Validation checks fail. A possible cause is the Assisted-by: Vibhav's Pi trailer. We did not examine the failure details.
  • The repository rules require an update to the feature table and the compatibility matrix in app/cli/internal/trace/README.md when a Trace feature changes. This PR does not change that file.

Claude Code helped with this review.

Read staged blob content from the exact object recorded in the index, even when refs/replace maps it to another object.

Assisted-by: Vibhav's Pi
Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
}

status, err := wt.Status()
output, err := commandOutput(exec.Command("git", "-C", repoRoot, "diff", "--cached",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is gogit implementation, so this command should be run with the library.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, why are these changes needed? wasn't StagedFiles working properly before?


// SnapshotIndex returns the content signature currently staged in git's index.
// It excludes symlinks and submodules to match SnapshotWorktree's file scope.
func (c *GoGitClient) SnapshotIndex(repoRoot string) (map[string]string, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these methods belong to GoGitClient, however use exec.Command("git"...) which would belong to the Exec implementation (not used in production).

@jiparis

jiparis commented Oct 16, 2026

Copy link
Copy Markdown
Member

Thanks @waveywaves, sorry for the last long ie-vibed response.
Basically I was trying to measure pros and cons of this approach (updatedInput). I think it's fine if we keep it only for this specific use case. There are edge scenarios where it wouldn't work (multiple hooks updating the input, narrow permissions), but I think they can be addressed in a different task.
General comments:

  • trace uses GoGit, so exec.Command("git) is not a valid option here.
  • What about other providers? (cursor, opencode). If they won't be included in this PR, please update internal/trace/README capabilities matrix to reflect it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

trace: a commit made in the same Bash call as the edit loses the session trailer and attestation

3 participants