SLOPSHOPPER

block-commit-attribution

No description.

newguard
A shopper browsing a rack in a slop shop
README

block-commit-attribution

Blocks a git commit whose message has a Co-Authored-By: trailer, for any co-author. The model then removes the trailer and runs the commit again, because you do not want attribution lines in commits.

One matcher, core.ts, serves three agents through two adapters. An adapter is the small file that connects the matcher to the hook API of one agent.

AgentAdapterLoaded by
Claude Codehooks/register.ts, a mod: tool.call on Bash returns { deny }CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
pi and omppi.ts: tool_call on bash returns { block, reason }extensions in dot_pi/agent/modify_settings.json and dot_omp/private_agent/private_config.yml

Codex does not run this hook. See docs/hooks/decisions.md.

What it checks

The hook runs before each shell call: Bash in Claude Code, bash in pi and omp. It blocks the call when both of these are true, case-insensitive:

  • commit is a whole word in the command. A letter, a digit, _, or - next to it makes a different word, so git log --grep 'Co-Authored-By: Claude', rg ... committed.txt, and git commit-tree pass.
  • The command holds co-authored-by:. The hook does not read the name after it, so it blocks every agent, also one that does not exist yet, with no list to keep current.

The hook also blocks a human co-author. In an agent's commit, a human trailer occurs only when you ask for one, or when the agent rewrites a message that already has one. In those cases, commit by hand.

Requirements

  • Claude Code: the mod needs Claude Code 2.1.296 or later, the oldest version tested.
  • pi and omp: they load pi.ts in their own runtime, so the hook needs no node on PATH.

How it works

  1. The adapter reads the command of the shell call. In pi and omp, pi.ts ignores other tools and a command that is not a string.
  2. violation() in core.ts tests the command for the two conditions in What it checks.
  3. If both are true, the adapter blocks the call with the block message. Else the call runs.

The check reads the whole command, so the hook also blocks a command that commits and only mentions the trailer. An example is a git commit and a cat of a file that holds the trailer, joined with &&. To run such a command, split it into two calls.

One gap remains: a message written to a file in an earlier call and passed with git commit -F <file> passes. A git commit-msg hook is the upgrade path.

What the model sees

When the hook blocks a call, the command does not run. The model gets this block message as the reason:

Blocked: remove the Co-Authored-By trailer from the commit message and run the commit again. The user does not want attribution lines in commits.

The hook runs in the agent process, so it has no exit code. A call that passes gives the model no output.

Settings

The hook has no variables or config files. AGENT_HOOKS_DISABLE does not turn it off, because it is a guard, not an advisory hook. See toggle-hook.

To turn the hook off:

  • Claude Code: start with claude-nohooks or another launch mode that sets disableAllHooks. These also turn off this mod (tested on 2026-10-19). See launch-modes.md.
  • pi and omp: remove the pi.ts line of this hook from extensions in the agent's settings.

Failure behavior

  • Claude Code: the hook fails open. Claude Code skips a mod hook that throws, and the call runs.
  • pi and omp: pi.ts does no I/O and catches nothing. If it throws on a malformed event, pi blocks that call, so the hook fails closed.

Testing

node test/check.ts checks the matcher and both adapters. See docs/testing.md.

Design notes

Why a hook

attribution.commit: "" in settings.json stops Claude Code from adding the trailer. It does not stop the model from writing the trailer itself, out of habit.

A permissions.deny rule such as Bash(git commit Co-Authored-By: Claude) blocks the -m form. A test on Claude Code 2.1.291 showed that it does not match the trailer in a heredoc body (git commit -F - <<'EOF'). The model uses that form most.

Files

FileDeployed to ~/.claude/hooks/block-commit-attribution/Role
core.tsYesMatcher and block message. No I/O.
hooks/register.ts, hooks/hooks.jsonYesClaude Code mod
dot_claude-plugin/plugin.jsonYes, as .claude-plugin/plugin.jsonPlugin manifest
pi.tsYespi and omp extension
README.md, docs/, test/NoKept in the repo by .chezmoiignore
Source 2 files
hooks/register.ts 16 lines
1/**
2 * Claude Code mod: denies a Bash call that commits with a
3 * `Co-Authored-By:` trailer. Fails open: a hook that throws is skipped
4 * and the call runs, as the command hook did.
5 */
6
7import type { Register } from "claude-code";
8import { violation } from "../core.ts";
9
10export const register: Register = (on) => {
11  on("tool.call", { tool: "Bash" }, async (_$, e, next) => {
12    const reason = violation(e.command);
13    return reason ? { deny: reason } : next(e);
14  });
15};
16
core.ts 29 lines
1/**
2 * The block-commit-attribution matcher: finds a `git commit` whose message
3 * has a `Co-Authored-By:` trailer. Pure: the adapters pass the command line
4 * in.
5 *
6 * Adapters: hooks/register.ts (Claude Code mod), pi.ts (pi and omp).
7 */
8
9// Any co-author, so a new agent needs no change. A human co-author is blocked
10// too: the user then commits by hand. Agents write Co-Authored-By and
11// Co-authored-by.
12const TRAILER = /co-authored-by:/i;
13// `commit` as a whole word, so `git log --grep 'Co-Authored-By: Claude'` and
14// `rg ... committed.txt` pass.
15const COMMIT = /(^|[^A-Za-z0-9_-])commit([^A-Za-z0-9_-]|$)/i;
16
17/** The reason the model reads when the call is blocked. */
18export const MESSAGE =
19  "Blocked: remove the Co-Authored-By trailer from the commit message and run the commit again. The user does not want attribution lines in commits.";
20
21/**
22 * The block message when the line commits with a co-author trailer, else
23 * undefined. shortcut: a message written to a file first and passed with
24 * `git commit -F <file>` passes; a git commit-msg hook is the upgrade path.
25 */
26export function violation(line: string): string | undefined {
27  return COMMIT.test(line) && TRAILER.test(line) ? MESSAGE : undefined;
28}
29