Claude-Session trailer still appended to commits and PR bodies with includeCoAuthoredBy: false (regression of #66504)

Status Open
Reported on v2.1.258
Maintainer reply None cached
Activity 7 comments · opened Sep 2, 2026

_Written by Claude (Opus 5) and posted on my behalf._

includeCoAuthoredBy: false has been set in my user settings throughout, and
the Claude-Session: trailer is still being appended to commit messages, and
the bare session URL to PR bodies.

This is not the same report as #72557, which asked for it to be opt-in. It is
opt-in now, and the opt-out does not hold.

Where it comes from

The session's own instructions direct it, regardless of the setting:

End git commit messages with:
Claude-Session: https://claude.ai/code/session_<id>
End PR bodies with:
https://claude.ai/code/session_<id>

The model is following an instruction that appears to be injected upstream of
the setting, so it has no way to know the user opted out. On the one occasion I
noticed and asked, it correctly identified the conflict — the setting is not
reaching whatever composes that instruction.

Scope

Not a one-off. Across six private repositories:

| dates | commits |
|---|---|
| 2026-08-15 .. 2026-09-01 | ~318 |

Spread over many separate sessions, most of them unattended at the time. The
majority postdate 2026-08-17, when #66504 was closed as COMPLETED — that is the
change which introduced the setting.

Version: 2.1.258.

Why it matters

The URL identifies a private session. In a commit trailer it is permanent and
rewriting history is the only removal. In a PR body it is visible to every
reviewer. Neither is recoverable by changing a setting afterwards.

The practical effect is that a user who has explicitly opted out accumulates
hundreds of these without being told, and only finds out by reading a diff
closely enough to notice a trailer they did not write.

What would resolve it

Either the setting reaches the instruction composer, or — if a different
setting governs this path — documenting which one, because includeCoAuthoredBy
is the only one that names this behaviour and it reads as the intended control.

A loud failure is not needed here; simply not emitting it when opted out is
enough.

Related: #66504 (completed 2026-08-17), #72557 (closed as duplicate of it).

View original on GitHub ↗

3 Comments

lllamnyp · 1 month ago
It is opt-in now

I do not think it is. I never opted in and today my global CLAUDE.md instruction were overridden to include disallowed trailers without warning.

khughitt · 1 month ago

Related: #92169

khughitt · 1 month ago

Still reproducing on 2.1.263.

The exact text behind @lllamnyp's report

@lllamnyp above says their global CLAUDE.md was overridden without warning, and #92169 describes the same shape. Here is the mechanism, verbatim — the instruction doesn't arrive as a suggestion the model weighs against user config, it arrives as a system message that explicitly claims precedence over earlier guidance:

Attribution for git commits and pull requests you create from here on (this replaces any earlier attribution guidance): - End git commit messages with: Claude-Session: https://claude.ai/code/session_<id>

That parenthetical is doing the damage. My CLAUDE.md says, plainly, "Do NOT add any AI-attribution trailer or footer to commit messages, PRs, or comments." A session that reads both is told the later system-level instruction supersedes the user file — which is a defensible reading of the words, and the wrong outcome. So this isn't only includeCoAuthoredBy: false failing to reach the instruction composer: users who never touch that setting and simply write the rule into CLAUDE.md are overridden too, and given what it's told, the model is arguably correct to do so. That makes it a broader problem than the title suggests.

I watched a session do exactly that today on 2.1.263: it composed a commit message ending in the trailer despite the CLAUDE.md rule, and only stopped because a hook blocked the call.

Workaround: a PreToolUse hook

Since the instruction wins on precedence, the fix has to sit outside the model. This hook blocks any git commit or gh pr create|edit whose message carries an attribution line, and exit code 2 hands the reason back to the model, which reissues the command without it. In practice the session drops the line and moves on — one wasted tool call, no lost work.

Save it, chmod +x, and wire it up in ~/.claude/settings.json:

{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [
  {"type": "command", "command": "~/.claude/hooks/block-ai-attribution.py"}]}]}}
#!/usr/bin/env python3
"""Claude Code PreToolUse hook on Bash: blocks a `git commit` or `gh pr create|edit`
whose message carries an AI attribution line.

Exit 2 blocks the call and hands stderr back to the model, which reissues the command
without the line. Bad input or a crash never blocks: the hook only ever blocks on a
match, so a bug here cannot wedge your session. Stdlib only.
"""
import json
import pathlib
import re
import shlex
import sys

# A session trailer, an AI co-author, a generated-with banner, and the bare session
# URL -- which is what a pull request body gets instead of a trailer.
ATTRIBUTION = re.compile(
    r"Claude-Session:[ \t]*[^\s'\"]+"
    r"|Co-Authored-By:[ \t]*Claude\b[^\n'\"]*"
    r"|(?:[^\s'\"]+[ \t]+)?Generated with \[?Claude Code\b[^\n'\"]*"
    r"|https://claude\.ai/code/session_\w+",
    re.IGNORECASE,
)

# A commit or PR write at the head of a shell segment: start of string or just after a
# separator, optionally behind VAR=value prefixes. `git -C <dir> commit` counts too.
# Deliberately a regex rather than a shell parser -- a false positive only means the
# command is then searched for an attribution line, which it almost certainly does not
# contain, so being loose here costs nothing.
WRITE = re.compile(
    r"(?:^|[;&|(\n])\s*"
    r"(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*"
    r"(?:git\s+(?:-C\s+\S+\s+)?commit\b|gh\s+pr\s+(?:create|edit)\b)"
)

# The message file a commit reads, so `git commit -F msg.txt` is checked as well.
MESSAGE_FILE = re.compile(r"^(?:-[a-zA-Z]*F|--file)(?:=(.+))?$")


def message_files(command, cwd):
    """Paths the command would read a commit message from, resolved against cwd."""
    try:
        words = shlex.split(command)
    except ValueError:  # unbalanced quotes, e.g. a heredoc body -- nothing to resolve
        return []
    paths = []
    for i, word in enumerate(words):
        found = MESSAGE_FILE.match(word)
        if not found:
            continue
        path = found.group(1)
        if path is None:
            path = words[i + 1] if i + 1 < len(words) else None
        if path and path != "-":
            paths.append(pathlib.Path(cwd) / path)
    return paths


def attribution(command, cwd):
    """The attribution line this command would write, or None.

    Once the command contains a commit or PR write, the WHOLE command is searched: the
    message usually sits in a heredoc body or a $(...) substitution rather than in the
    matched segment itself.
    """
    if not WRITE.search(command):
        return None
    texts = [command]
    for path in message_files(command, cwd):
        try:
            texts.append(path.read_text())
        except OSError:
            pass
    for text in texts:
        found = ATTRIBUTION.search(text)
        if found:
            return found.group(0).strip()
    return None


def main():
    try:
        payload = json.loads(sys.stdin.read() or "null")
        command = payload["tool_input"]["command"]
        if not isinstance(command, str):
            return 0
    except (json.JSONDecodeError, KeyError, TypeError):
        return 0
    cwd = payload.get("cwd") or "."
    match = attribution(command, cwd)
    if match is None:
        return 0
    sys.stderr.write(
        f"block-ai-attribution: refusing to write an AI attribution line: {match!r}. "
        "Your CLAUDE.md forbids AI attribution trailers in commits and pull requests; "
        "drop the line and rerun.\n"
    )
    return 2


if __name__ == "__main__":
    try:
        code = main()
    except Exception as error:  # noqa: BLE001 - a broken hook must not block every command
        sys.stderr.write(f"block-ai-attribution: {type(error).__name__}: {error}\n")
        code = 0
    sys.exit(code)

Blocked commands look like this to the model, which is enough for it to recover on its own:

block-ai-attribution: refusing to write an AI attribution line:
'Claude-Session: https://claude.ai/code/session_<id>'. Your CLAUDE.md forbids AI
attribution trailers in commits and pull requests; drop the line and rerun.

What it does and doesn't cover

Verified against 13 cases: -m with the trailer, a heredoc body, git commit -F msg.txt where the file carries it, gh pr create --body, git -C <dir> commit, a VAR=x git commit prefix, and the Co-Authored-By / Generated with Claude Code variants — all blocked. Plain commits, git commit -F on a clean file, grep "Claude-Session" ./logs, echo "Co-Authored-By: Claude" and fetching a session URL with curl — all allowed.

Known gaps, in case anyone extends it:

  • git commit -m "$(cat msg)" — the substitution isn't executed, so a trailer that only exists inside it isn't seen.
  • Segment detection is a regex, not a shell parser. A commit invoked behind an unusual separator could slip past. Erring loose was deliberate: a false positive costs nothing, since the command is then merely searched for a trailer it doesn't have.
  • It only guards the Bash path. Anything that writes a commit without going through a Bash tool call is untouched.
  • It's a net, not a fix. It catches the line after the model has already composed it. The instruction still says what it says.

Happy to fold in refinements if others hit cases I've missed.

A suggestion

Beyond making includeCoAuthoredBy: false reach the composer: drop the "this replaces any earlier attribution guidance" clause. Even with the setting honoured, that sentence tells every session to override a user's CLAUDE.md on this exact point — which is what @lllamnyp hit and what #92169 is about. User configuration should win here, and right now the injected text explicitly says it doesn't.

Showing cached comments. Read the full discussion on GitHub ↗